Build a Multi-Client Chat with Java

Build a TCP chat system with private messaging and file transfer in Java.

Introduction

30 Second Summary

Every time you send a message on WhatsApp, Slack, or Discord, a server somewhere is receiving your text, figuring out who should see it, and pushing it to the right screens in milliseconds. That's socket programming at work.

In this project, you will build a complete multi-client chat system from scratch using Java sockets and JavaFX. You'll start with a basic broadcast server, add private messaging between users, and finish with the ability to send emojis, files, images, and videos between clients.

What You'll Build

A full-stack chat application with a JavaFX GUI, featuring a multi-threaded server that handles simultaneous client connections, message routing, and binary file transfer.

By the end of this project, you'll have:

  • A multi-threaded chat server that accepts and manages multiple client connections simultaneously using TCP sockets.
  • A private messaging system where clients can send direct messages to specific users through custom protocol commands.
  • A media transfer protocol that sends emojis, files, images, and videos between clients using custom binary framing.
  • Secret Mission: Add a typing indicator that shows other users when someone is currently typing a message.

Are there any prerequisites?

You'll need basic Java syntax knowledge, IntelliJ IDEA installed, and Java 21 JDK installed on your machine. Maven comes bundled with IntelliJ, so no separate install is needed.

Set Up the Maven Project and FXML Layouts

Before you can write any networking or threading code, you need a properly configured project. This project uses JavaFX for the GUI and Maven to manage dependencies.

In this step, you will create the Maven project in IntelliJ IDEA, add the JavaFX dependencies, and build out the file structure for both the server and client applications.

In this step, get ready to:
  • Create a Maven project in IntelliJ IDEA with JavaFX dependencies.
  • Create placeholder Java classes for the server and client.
  • Create FXML layout files that define the chat window UI.
Create the Maven project in IntelliJ IDEA

Maven is a build tool that manages your project's dependencies and compilation process. It uses a file called pom.xml to track everything your project needs.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.
  • On the Welcome screen, click New Project.
  • In the Name field, enter JavaSocketProgramming.
  • Select Java as the language on the left.
  • Select Maven as the Build system.
  • Set the JDK to your Java 21 installation.
  • Uncheck Add sample code.
  • Expand Advanced Settings at the bottom of the dialog.
  • Set GroupId to org.example.
  • Set ArtifactId to JavaSocketProgramming.
  • Click Create.

IntelliJ IDEA generates a basic pom.xml with compiler settings and a standard Maven directory structure.

What is a pom.xml?

The pom.xml (Project Object Model) is Maven's configuration file. It tells Maven which libraries to download, which Java version to compile with, and how to build your project.

The generated pom.xml has Java compiler settings but no JavaFX dependencies yet. You need to add the JavaFX libraries and the Maven plugin that knows how to run JavaFX apps.

  • In the Project tool window on the left, double-click pom.xml to open it.
  • Replace the entire contents of pom.xml with the following configuration:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>JavaSocketProgramming</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <!-- JavaFX libraries for building the GUI -->
    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>21</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>21</version>
        </dependency>
    </dependencies>

    <!-- Plugin that lets Maven run JavaFX applications -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>org.example.ServerInitializer</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

What does this pom.xml do?

  • javafx-controls provides the UI components you need: TextArea, TextField, Button, and more.
  • javafx-fxml lets you define your UI layout in XML files instead of writing it in Java code.
  • javafx-maven-plugin version 0.0.8 allows you to run JavaFX apps with a single Maven command.
  • mainClass points to org.example.ServerInitializer, which will be the server's entry point.
  • Save pom.xml with Cmd+S (macOS) or Ctrl+S (Windows).
  • A notification appears at the top of the editor asking to load Maven changes. Click Load Maven Changes (or click the floating Maven sync icon).

IntelliJ downloads the JavaFX libraries in the background. You can see progress in the status bar at the bottom of the window.

Not seeing the Load Maven Changes notification?

If the notification does not appear, right-click pom.xml in the Project tool window and select Maven, then Reload Project.

help me reload Maven dependencies in IntelliJ IDEA

Create the Java placeholder files

Your chat system needs four Java files: two for the server (an entry point and a controller) and two for the client (same pattern). You will create these now as placeholders and fill them in during the next steps.

  • In the Project tool window, expand src, then main, then java.
  • Right-click the java folder.
  • Select New, then Package.
  • Enter org.example and press Enter.

This creates the src/main/java/org/example/ directory where all your Java files will live.

  • Right-click the org.example package.
  • Select New, then Java Class.
  • Enter ServerInitializer and press Enter.
  • Replace the contents of ServerInitializer.java with the following code:
package org.example;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

// Entry point for the server application
public class ServerInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
    }
}

What does this class do?

ServerInitializer extends Application, which is the base class for all JavaFX apps. The launch(args) call starts the JavaFX runtime, and start() is where you load the window. You will fill in the start() method in a later step.

  • Create another Java class in the same org.example package named ClientInitializer.
  • Replace its contents with the following code:
package org.example;

import javafx.application.Application;
import javafx.stage.Stage;

import java.io.IOException;

// Entry point for the client application
public class ClientInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
    }
}
  • Create another Java class in org.example named ServerController.
  • Replace its contents with the following code:
package org.example;

import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.net.URL;
import java.util.ResourceBundle;

// Handles the server window's UI logic
public class ServerController implements Initializable {

    private Button server_send_btn;
    private TextArea server_text_area;
    private TextField server_text_field;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
    }
}

What is a controller?

In JavaFX, a controller handles the logic for a specific window. It implements Initializable so JavaFX calls its initialize() method after loading the FXML layout.

The three fields (server_send_btn, server_text_area, server_text_field) will be connected to the UI components defined in the FXML file.

  • Create the final Java class in org.example named ClientController.
  • Replace its contents with the following code:
package org.example;

import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.net.URL;
import java.util.ResourceBundle;

// Handles the client window's UI logic
public class ClientController implements Initializable {

    private Button client_send_btn;
    private TextArea client_text_area;
    private TextField client_text_field;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
    }
}
  • Save all files with Cmd+S (macOS) or Ctrl+S (Windows).
  • In the Project tool window, expand src/main/java/org/example and confirm you see all four files: ClientController, ClientInitializer, ServerController, and ServerInitializer.

Seeing red underlines in your imports?

If IntelliJ shows errors on the javafx import lines, it means Maven has not finished downloading the dependencies. Wait for the progress bar in the bottom status bar to finish. If the errors persist, right-click pom.xml and select Maven, then Reload Project.

help me fix JavaFX import errors in IntelliJ

Create the FXML layout files

FXML files define the visual layout of your windows using XML. Each chat window needs a TextArea to display messages, a TextField to type messages, and a Button to send them.

  • In the Project tool window, expand src/main.
  • Right-click the resources folder.
  • Select New, then File.
  • Enter server.fxml and press Enter.
  • Paste the following layout into server.fxml:
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.ServerController">
    <children>
        <TextArea fx:id="server_text_area" layoutX="14.0" layoutY="14.0" prefHeight="320.0" prefWidth="572.0" />
        <TextField fx:id="server_text_field" layoutX="14.0" layoutY="350.0" prefHeight="30.0" prefWidth="470.0" />
        <Button fx:id="server_send_btn" layoutX="500.0" layoutY="350.0" mnemonicParsing="false" onAction="#sendMessage" prefHeight="30.0" prefWidth="86.0" text="Send" />
    </children>
</AnchorPane>

What does this FXML do?

  • AnchorPane is a container that lets you position child elements at exact coordinates.
  • fx:controller connects this layout to ServerController, so the controller can interact with the UI.
  • fx:id attributes match the field names in your controller class (e.g., server_text_area).
  • onAction="#sendMessage" tells JavaFX to call the sendMessage() method in the controller when the button is clicked.
  • Right-click the resources folder again.
  • Select New, then File.
  • Enter client.fxml and press Enter.
  • Paste the following layout into client.fxml:
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.ClientController">
    <children>
        <TextArea fx:id="client_text_area" editable="false" layoutX="14.0" layoutY="14.0" prefHeight="320.0" prefWidth="572.0" />
        <TextField fx:id="client_text_field" layoutX="14.0" layoutY="350.0" prefHeight="30.0" prefWidth="470.0" />
        <Button fx:id="client_send_btn" layoutX="500.0" layoutY="350.0" mnemonicParsing="false" onAction="#sendMessage" prefHeight="30.0" prefWidth="86.0" text="Send" />
    </children>
</AnchorPane>

How is client.fxml different from server.fxml?

The client TextArea has editable="false" so users can only read incoming messages, not type directly into the chat display. The fx:controller points to ClientController instead of ServerController.

  • Save both FXML files.
  • Confirm server.fxml and client.fxml appear inside the src/main/resources folder in the Project tool window.

Now verify that everything compiles correctly.

  • Open IntelliJ's built-in terminal by clicking Terminal in the bottom tool bar (or press Alt+F12).
  • Compile the project by running this command:
mvn compile

You should see BUILD SUCCESS in the terminal output.

Seeing BUILD FAILURE?

  • Check that your pom.xml has no typos in the dependency coordinates. The groupId must be org.openjfx (not org.javafx).
  • Make sure you clicked Load Maven Changes after editing pom.xml. Maven needs to download the libraries before it can compile.
  • Verify your Java files are inside src/main/java/org/example/. If they are in the wrong directory, Maven cannot find them.

help me fix my Maven build failure

✔️ Awesome, I've got everything!

Double check you have saved all your files. Your project structure should show four Java classes and two FXML files.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point in the project.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>JavaSocketProgramming</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>21</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>21</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>org.example.ServerInitializer</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
package org.example;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

public class ServerInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
    }
}
package org.example;

import javafx.application.Application;
import javafx.stage.Stage;

import java.io.IOException;

public class ClientInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
    }
}
package org.example;

import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.net.URL;
import java.util.ResourceBundle;

public class ServerController implements Initializable {

    private Button server_send_btn;
    private TextArea server_text_area;
    private TextField server_text_field;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
    }
}
package org.example;

import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.net.URL;
import java.util.ResourceBundle;

public class ClientController implements Initializable {

    private Button client_send_btn;
    private TextArea client_text_area;
    private TextField client_text_field;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
    }
}
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.ServerController">
    <children>
        <TextArea fx:id="server_text_area" layoutX="14.0" layoutY="14.0" prefHeight="320.0" prefWidth="572.0" />
        <TextField fx:id="server_text_field" layoutX="14.0" layoutY="350.0" prefHeight="30.0" prefWidth="470.0" />
        <Button fx:id="server_send_btn" layoutX="500.0" layoutY="350.0" mnemonicParsing="false" onAction="#sendMessage" prefHeight="30.0" prefWidth="86.0" text="Send" />
    </children>
</AnchorPane>
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.ClientController">
    <children>
        <TextArea fx:id="client_text_area" editable="false" layoutX="14.0" layoutY="14.0" prefHeight="320.0" prefWidth="572.0" />
        <TextField fx:id="client_text_field" layoutX="14.0" layoutY="350.0" prefHeight="30.0" prefWidth="470.0" />
        <Button fx:id="client_send_btn" layoutX="500.0" layoutY="350.0" mnemonicParsing="false" onAction="#sendMessage" prefHeight="30.0" prefWidth="86.0" text="Send" />
    </children>
</AnchorPane>

Your project foundation is solid. Next up, you will learn how sockets and threads work, then bring the server to life with real networking code.

Build the Server with Sockets and Threads

Your project structure is set up and compiling. Now it is time to bring the server to life.

In this step, you will build a server that opens a TCP connection on port 3000 and waits for clients to connect. Each client that connects gets its own dedicated background thread so the server can handle multiple conversations at the same time without freezing.

In this step, get ready to:
  • Implement ServerInitializer to launch the server GUI window.
  • Create a ServerSocket that listens for connections on port 3000.
  • Handle multiple clients simultaneously using background threads.
Implement the ServerInitializer

Every JavaFX application starts with a class that extends Application. This class is the entry point that JavaFX uses to launch the GUI.

What is a TCP socket?

Think of a TCP socket like a phone call. One side dials (the client) and the other side picks up (the server). Once connected, both sides can send data back and forth until one hangs up.

A ServerSocket is like a receptionist sitting at the front desk. It listens on a specific port number and accepts incoming calls. Each accepted call becomes its own Socket object representing that one conversation.

What is the JavaFX Application lifecycle?

When you call launch(args) in main(), JavaFX creates the Application Thread and calls your start(Stage) method. The Stage is your window. You load an FXML file into a Scene, set the Scene on the Stage, and call show() to display it.

  • Open ServerInitializer.java in IntelliJ and replace its entire contents with the following code:
package org.example;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

public class ServerInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
        stage.setTitle("Chat Server");
        stage.setScene(new Scene(FXMLLoader.load(getClass().getResource("/server.fxml"))));
        stage.show();
    }
}

What does this code do?

  • main() calls launch(args) which boots up the JavaFX runtime and calls your start() method.
  • start() sets the window title to "Chat Server", loads the server.fxml layout into a Scene, and displays the window.
  • FXMLLoader.load() reads the FXML file and creates all the UI controls (TextArea, TextField, Button) you defined earlier.
  • Save ServerInitializer.java.
  • Run the server by right-clicking ServerInitializer.java in the Project panel and selecting Run 'ServerInitializer.main()'.
  • You should see an empty window titled "Chat Server" with a text area, text field, and Send button.

Window not appearing?

  • Check that the file path in getResource("/server.fxml") starts with a forward slash. Without it, Java looks in the wrong location.
  • Make sure server.fxml is inside src/main/resources/ (not src/main/java/).
  • If you see a NullPointerException, rerun mvn compile first so Maven copies resources to the build folder.

Help me debug my ServerInitializer window not appearing

Build the server listening loop

The server window opens, but it does not do anything yet. Now you will write the core server logic: a ServerSocket that binds to port 3000 and waits for clients to connect.

What is blocking I/O?

When you call serverSocket.accept(), the thread running that code completely freezes until a client connects. This is called blocking I/O. The same thing happens with readUTF(). The thread stops and waits until data arrives.

If you put blocking calls on the JavaFX Application Thread (the one that draws your GUI), the entire window freezes. Buttons stop responding. Text stops rendering. The application looks crashed. That is why all network operations MUST run on a separate background thread.

What is Platform.runLater?

JavaFX has one strict rule: only the Application Thread can touch the GUI. If a background thread tries to update a TextArea or enable a Button, the app crashes or behaves unpredictably.

Platform.runLater() is the bridge. It takes a piece of code and drops it into the Application Thread's to-do queue. The Application Thread picks it up and runs it when it is ready. This is how background threads safely update the UI.

  • Open ServerController.java and replace its entire contents with the following code:
package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.ResourceBundle;

public class ServerController implements Initializable {

    @FXML private Button server_send_btn;
    @FXML private TextArea server_text_area;
    @FXML private TextField server_text_field;

    // Stores the output stream for every connected client
    private final List<DataOutputStream> clients = new ArrayList<>();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        // Disable input controls until the server is ready
        server_text_area.setEditable(false);
        server_text_field.setDisable(true);
        server_send_btn.setDisable(true);
        startServer();
    }

    private void startServer() {
        // Run the server on a background thread so the GUI stays responsive
        Thread serverThread = new Thread(() -> {
            try {
                ServerSocket serverSocket = new ServerSocket(3000);
                appendMessage("Server started. Waiting for clients...");

                // Enable input controls now that the server is running
                Platform.runLater(() -> {
                    server_text_field.setDisable(false);
                    server_send_btn.setDisable(false);
                });

                // Accept loop: blocks until a client connects, then repeats
                while (true) {
                    Socket clientSocket = serverSocket.accept();
                    appendMessage("Client connected: " + clientSocket.getRemoteSocketAddress());

                    // Give each client its own thread for reading messages
                    Thread clientThread = new Thread(() -> handleClient(clientSocket));
                    clientThread.setDaemon(true);
                    clientThread.start();
                }

            } catch (IOException e) {
                appendMessage("Server error: " + e.getMessage());
            }
        });
        serverThread.setDaemon(true);
        serverThread.start();
    }
}

What does this code do?

  • @FXML annotations tell JavaFX to inject the UI controls defined in server.fxml into these fields when the FXML loads.
  • clients is a list that stores one DataOutputStream per connected client. When you want to send a message to everyone, you iterate this list.
  • initialize() disables the input fields (so users cannot type before the server is ready) and kicks off startServer().
  • startServer() creates a background thread, opens a ServerSocket on port 3000, and loops forever calling accept(). Each time a client connects, it spawns a new daemon thread for that client.
  • setDaemon(true) means this thread dies automatically when you close the window. Without it, the JVM would hang because non-daemon threads keep it alive.

IntelliJ will show red errors because handleClient() and appendMessage() do not exist yet. You will add them next.

Handle each client on its own thread

Each client connection needs to be read independently. If you tried reading from five clients on a single thread, the readUTF() call for Client 1 would block, and Clients 2 through 5 would be ignored until Client 1 sends something.

What is the thread-per-client pattern?

Each connected client gets its own dedicated thread. That thread runs a loop that blocks on readUTF(), waiting for data from that specific client. When a message arrives, the thread wakes up, processes it, and goes back to waiting.

This means five clients produce five threads, each independently blocked on its own input stream. No client has to wait for another client to finish talking.

  • In ServerController.java, add the following handleClient() method inside the class, after the closing brace of startServer():
    private void handleClient(Socket clientSocket) {
        try {
            DataInputStream in = new DataInputStream(clientSocket.getInputStream());
            DataOutputStream out = new DataOutputStream(clientSocket.getOutputStream());

            // Register this client's output stream for broadcasting
            clients.add(out);

            // Read loop: blocks until the client sends a message
            while (true) {
                String message = in.readUTF();
                appendMessage("Client [" + clientSocket.getRemoteSocketAddress() + "]: " + message);
                broadcast(message);
            }

        } catch (IOException e) {
            appendMessage("A client disconnected.");
        }
    }

What does this code do?

  • DataInputStream wraps the client's raw input stream so you can read structured data. readUTF() reads one complete string at a time (it knows the length because writeUTF() prepends a 2-byte length header).
  • DataOutputStream wraps the output stream. It gets stored in the clients list so the server can send messages back to this client.
  • When readUTF() throws an IOException, it means the client disconnected. The loop breaks and the method ends.
  • Next, add the broadcast() method after handleClient():
    private void broadcast(String message) {
        for (DataOutputStream out : clients) {
            try {
                out.writeUTF(message);
                out.flush();
            } catch (IOException e) {
                // skip disconnected client
            }
        }
    }

What does this code do?

broadcast() loops through every stored client output stream and writes the message. flush() pushes the bytes out immediately instead of buffering. If a client has already disconnected, the IOException is caught and silently skipped.

  • Add the sendMessage() method after broadcast(). This is the FXML button handler that lets the server operator type and send messages:
    @FXML
    public void sendMessage() {
        String msg = server_text_field.getText().trim();
        if (msg.isEmpty()) return;

        broadcast(msg);
        appendMessage("Server (You): " + msg);
        server_text_field.clear();
    }

What does this code do?

When the server operator clicks Send, this method reads the text field, broadcasts the message to all clients, displays it locally in the server's chat area, and clears the field. The @FXML annotation connects it to the onAction="#sendMessage" attribute in server.fxml.

  • Finally, add the appendMessage() helper method after sendMessage(), just before the closing } of the class:
    private void appendMessage(String message) {
        Platform.runLater(() -> server_text_area.appendText(message + "\n"));
    }

Why does appendMessage use Platform.runLater?

appendMessage() is called from background threads (the server thread and each client thread). Since it updates the TextArea (a GUI control), it must push that work onto the JavaFX Application Thread using Platform.runLater(). Without this wrapper, the app would crash or display garbled text.

  • Save ServerController.java.
  • Confirm all red errors have disappeared in IntelliJ. The file should compile cleanly now that all methods exist.
  • Run ServerInitializer again by right-clicking the file and selecting Run 'ServerInitializer.main()'.
  • You should see the Chat Server window appear with the text "Server started. Waiting for clients..." in the text area. The text field and Send button should now be enabled.

Seeing "Address already in use" error?

  • This means port 3000 is already taken by another process (possibly a previous run that did not shut down). Close any other running instances of the server in IntelliJ's Run panel.
  • If that does not work, restart IntelliJ to kill orphan processes.
  • Make sure your ServerSocket uses port 3000 exactly as shown in the code.

Help me debug my server not starting on port 3000

✔️ Awesome, I've got everything!

Great. Double check you have saved both ServerInitializer.java and ServerController.java.

ⓧ I'd like to double check the full code

Here is the complete ServerInitializer.java:

package org.example;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

public class ServerInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
        stage.setTitle("Chat Server");
        stage.setScene(new Scene(FXMLLoader.load(getClass().getResource("/server.fxml"))));
        stage.show();
    }
}

Here is the complete ServerController.java:

package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.ResourceBundle;

public class ServerController implements Initializable {

    @FXML private Button server_send_btn;
    @FXML private TextArea server_text_area;
    @FXML private TextField server_text_field;

    private final List<DataOutputStream> clients = new ArrayList<>();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        server_text_area.setEditable(false);
        server_text_field.setDisable(true);
        server_send_btn.setDisable(true);
        startServer();
    }

    private void startServer() {
        Thread serverThread = new Thread(() -> {
            try {
                ServerSocket serverSocket = new ServerSocket(3000);
                appendMessage("Server started. Waiting for clients...");

                Platform.runLater(() -> {
                    server_text_field.setDisable(false);
                    server_send_btn.setDisable(false);
                });

                while (true) {
                    Socket clientSocket = serverSocket.accept();
                    appendMessage("Client connected: " + clientSocket.getRemoteSocketAddress());

                    Thread clientThread = new Thread(() -> handleClient(clientSocket));
                    clientThread.setDaemon(true);
                    clientThread.start();
                }

            } catch (IOException e) {
                appendMessage("Server error: " + e.getMessage());
            }
        });
        serverThread.setDaemon(true);
        serverThread.start();
    }

    private void handleClient(Socket clientSocket) {
        try {
            DataInputStream in = new DataInputStream(clientSocket.getInputStream());
            DataOutputStream out = new DataOutputStream(clientSocket.getOutputStream());

            clients.add(out);

            while (true) {
                String message = in.readUTF();
                appendMessage("Client [" + clientSocket.getRemoteSocketAddress() + "]: " + message);
                broadcast(message);
            }

        } catch (IOException e) {
            appendMessage("A client disconnected.");
        }
    }

    private void broadcast(String message) {
        for (DataOutputStream out : clients) {
            try {
                out.writeUTF(message);
                out.flush();
            } catch (IOException e) {
                // skip disconnected client
            }
        }
    }

    @FXML
    public void sendMessage() {
        String msg = server_text_field.getText().trim();
        if (msg.isEmpty()) return;

        broadcast(msg);
        appendMessage("Server (You): " + msg);
        server_text_field.clear();
    }

    private void appendMessage(String message) {
        Platform.runLater(() -> server_text_area.appendText(message + "\n"));
    }
}

Your server is alive and listening. Next up, you will build the client side so multiple windows can connect and start chatting in real time.

Build the Client and Test Broadcast Messaging

Your server is alive and listening on port 3000. It can accept connections, read messages, and broadcast them to every connected client. The missing piece is the client itself.

In this step, you will build the client side of your chat system. The client is the mirror image of the server: it connects to the server's address, sends messages through a DataOutputStream, and listens for incoming messages on a background thread.

In this step, get ready to:
  • Implement ClientInitializer to open five client windows.
  • Implement ClientController with socket connection and a background reading thread.
  • Run the full system and verify broadcast messaging works end to end.
Implement ClientInitializer to open five chat windows

When a client creates a Socket object with new Socket("127.0.0.1", 3000), Java performs a TCP three-way handshake behind the scenes. The client sends a "SYN" packet to the server, the server responds with "SYN-ACK", and the client finishes with an "ACK". Only after this exchange completes does the Socket constructor return. If the server is not running, it throws an IOException immediately.

What is the TCP three-way handshake?

Think of it like a phone call. You dial (SYN), the other person picks up and says "hello" (SYN-ACK), and you confirm "I can hear you" (ACK). Only then can both sides start talking.

The Socket constructor is a blocking call. Your code pauses on that line until the handshake either succeeds or fails. This is why order matters: the server MUST be running before clients try to connect.

The ClientInitializer class is a JavaFX Application that opens five separate client windows. Each window loads its own copy of client.fxml, which creates a separate ClientController instance that connects independently to the server.

  • Replace the entire contents of ClientInitializer.java with the following code:
package org.example;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

public class ClientInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
        // Open 5 separate client windows, each with its own controller
        for (int i = 0; i < 5; i++) {
            FXMLLoader loader = new FXMLLoader(getClass().getResource("/client.fxml"));
            Stage clientStage = new Stage();
            clientStage.setTitle("Client " + (i + 1));
            clientStage.setScene(new Scene(loader.load()));
            clientStage.show();
        }
    }
}

What does this code do?

  • The for loop runs 5 times, creating a fresh FXMLLoader, Stage, and Scene each iteration.
  • Each FXMLLoader.load() call creates a NEW instance of ClientController. This means each window has its own socket connection to the server.
  • Each Stage gets a unique title ("Client 1" through "Client 5") so you can tell the windows apart during testing.
  • Save ClientInitializer.java.

Seeing a red underline on the import?

Make sure your import statements match exactly. IntelliJ may auto-import a different Stage class. The correct import is javafx.stage.Stage. Help me fix my ClientInitializer imports.

Implement the client's socket connection and reading thread

The server uses DataInputStream and DataOutputStream to communicate. The client must use the exact same pair. These two classes always work together: one side writes with writeUTF() and the other reads with readUTF(). Each writeUTF() call automatically prepends a 2-byte length header, so readUTF() knows exactly how many bytes to read.

Why does readUTF() need a background thread?

readUTF() is a blocking call. It freezes the current thread until data arrives from the server. If you call it on the JavaFX Application Thread, the entire window would freeze and become unresponsive.

The solution is the same pattern you used on the server: run the blocking read loop on a background thread. When a message arrives, use Platform.runLater() to safely update the TextArea on the JavaFX Application Thread.

  • Replace the entire contents of ClientController.java with the following code:
package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.URL;
import java.util.ResourceBundle;

public class ClientController implements Initializable {

    @FXML private Button client_send_btn;
    @FXML private TextArea client_text_area;
    @FXML private TextField client_text_field;

    // Socket and stream fields for communicating with the server
    private Socket remoteSocket;
    private DataOutputStream dataOutputStream;
    private DataInputStream dataInputStream;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        setClient();
    }

    private void setClient() {
        try {
            // TCP handshake happens here - connects to server on port 3000
            remoteSocket = new Socket("127.0.0.1", 3000);
            System.out.println("Connected to server.");
            dataOutputStream = new DataOutputStream(remoteSocket.getOutputStream());
            dataInputStream = new DataInputStream(remoteSocket.getInputStream());

            // Background thread to listen for incoming messages
            new Thread(() -> {
                while (true) {
                    try {
                        String message = dataInputStream.readUTF();
                        toChat("Server: " + message);
                    } catch (IOException e) {
                        System.out.println("Server disconnected.");
                        break;
                    }
                }
            }).start();

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    // Safely update the TextArea from any thread
    private void toChat(String txt) {
        Platform.runLater(() -> client_text_area.appendText(txt + "\n"));
    }
}

What does this code do?

  • The @FXML annotations connect the Java fields to the FXML-defined UI elements. Without these, the controller cannot access the TextArea or TextField.
  • initialize() calls setClient() immediately. This means each client window connects to the server the instant it opens.
  • new Socket("127.0.0.1", 3000) performs the TCP handshake with the server. 127.0.0.1 is "localhost" (your own machine).
  • The background Thread loops calling readUTF(), which blocks until the server sends a message. When a message arrives, toChat() posts the UI update to the JavaFX Application Thread via Platform.runLater().
  • If the server disconnects, readUTF() throws IOException, the loop breaks, and the thread ends cleanly.
  • Save ClientController.java.
  • Verify it compiles by opening the IntelliJ terminal and running:
mvn compile

You should see BUILD SUCCESS in the terminal output. The project compiles even though sendMessage() is not implemented yet. You will add it next.

Seeing BUILD FAILURE?

Check that all your import statements match exactly. A common issue is missing the import java.io.DataInputStream or import java.io.DataOutputStream lines. Also verify that your class still says implements Initializable. Help me fix my ClientController compilation error.

Add sendMessage() and test the full chat system

The client can now connect and receive messages, but it cannot send any. The client.fxml file already references a sendMessage() method via its onAction="#sendMessage" attribute on the Send button. You need to add this method to your controller.

  • In ClientController.java, add the following sendMessage() method below the setClient() method and above the toChat() method:
    @FXML
    public void sendMessage() {
        String msg = client_text_field.getText().trim();
        if (msg.isEmpty()) {
            return;
        }
        try {
            // Send the message to the server
            dataOutputStream.writeUTF(msg);
            dataOutputStream.flush();
            // Display the message locally
            toChat("You: " + msg);
            client_text_field.clear();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

What does this code do?

  • getText().trim() reads the TextField and removes leading/trailing whitespace. If the result is empty, the method returns early to avoid sending blank messages.
  • writeUTF(msg) sends the message to the server. The server's handleClient() thread receives it, calls broadcast(), and relays it to all connected clients.
  • flush() forces the data to send immediately rather than waiting for the buffer to fill.
  • toChat("You: " + msg) displays the message locally. The sender sees their own message without waiting for the server to echo it back.
  • Save ClientController.java.

Red underline on @FXML?

Make sure the @FXML annotation is on the line directly above public void sendMessage(). Also confirm you have import javafx.fxml.FXML; at the top of the file. Help me fix the @FXML annotation error.

✔️ Awesome, I've got everything!

Great. Double check you have saved ClientController.java before moving on to testing.

ⓧ I'd like to double check the full code

Here is the complete ClientController.java file for comparison:

package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.URL;
import java.util.ResourceBundle;

public class ClientController implements Initializable {

    @FXML private Button client_send_btn;
    @FXML private TextArea client_text_area;
    @FXML private TextField client_text_field;

    private Socket remoteSocket;
    private DataOutputStream dataOutputStream;
    private DataInputStream dataInputStream;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        setClient();
    }

    private void setClient() {
        try {
            remoteSocket = new Socket("127.0.0.1", 3000);
            System.out.println("Connected to server.");
            dataOutputStream = new DataOutputStream(remoteSocket.getOutputStream());
            dataInputStream = new DataInputStream(remoteSocket.getInputStream());

            new Thread(() -> {
                while (true) {
                    try {
                        String message = dataInputStream.readUTF();
                        toChat("Server: " + message);
                    } catch (IOException e) {
                        System.out.println("Server disconnected.");
                        break;
                    }
                }
            }).start();

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @FXML
    public void sendMessage() {
        String msg = client_text_field.getText().trim();
        if (msg.isEmpty()) {
            return;
        }
        try {
            dataOutputStream.writeUTF(msg);
            dataOutputStream.flush();
            toChat("You: " + msg);
            client_text_field.clear();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void toChat(String txt) {
        Platform.runLater(() -> client_text_area.appendText(txt + "\n"));
    }
}

And here is the complete ClientInitializer.java:

package org.example;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

public class ClientInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
        for (int i = 0; i < 5; i++) {
            FXMLLoader loader = new FXMLLoader(getClass().getResource("/client.fxml"));
            Stage clientStage = new Stage();
            clientStage.setTitle("Client " + (i + 1));
            clientStage.setScene(new Scene(loader.load()));
            clientStage.show();
        }
    }
}

Now it is time to run the full system and see your chat in action. The order matters: the server must start first so it is listening when clients try to connect.

  • In the IntelliJ Project panel on the left, navigate to src/main/java/org/example/.
  • Right-click ServerInitializer.java and select Run 'ServerInitializer.main()'.

A window titled "Chat Server" should appear. The TextArea inside it should display "Server started. Waiting for clients..." and the text field and Send button should now be enabled.

  • Right-click ClientInitializer.java and select Run 'ClientInitializer.main()'.

Five client windows should appear, each titled "Client 1" through "Client 5". In the server window, you should see five "Client connected:" messages appear.

  • In any client window, type a message into the text field and click Send.
  • Check the other client windows. The message should appear in all of them prefixed with "Server:".
  • In the server window, type a message and click Send. All five client windows should display it.

What should I see?

When Client 1 sends "Hello everyone", Client 1's TextArea shows "You: Hello everyone". All other client windows show "Server: Hello everyone". The server window shows "Client [/127.0.0.1:XXXXX]: Hello everyone" where XXXXX is the client's port number.

This is broadcast messaging. Every message from any client goes to the server, which relays it to ALL connected clients (including the sender, though the sender already displays it locally).

Client windows don't open or crash immediately?

  • Make sure the server is running BEFORE you start ClientInitializer. If the server is not listening, the Socket constructor throws an exception and the client crashes.
  • Check the IntelliJ console for a ConnectException: Connection refused message. This confirms the server is not running on port 3000.
  • If you see Address already in use when starting the server, a previous server instance is still running. Stop all run configurations in IntelliJ (click the red square) and try again.

Help me debug why my client won't connect to the server.

Your chat system is working. Every client can send messages and every other client receives them in real time. Next up, you will add usernames so clients can identify each other instead of showing generic socket addresses.

Add Usernames and Private Messaging

Your chat system broadcasts every message to every client. That works for a public chatroom, but real chat applications need two things: a way to identify who sent a message, and a way to send messages privately to a specific person.

In this step, you will design a simple text-based protocol that gives each client a username on connect and routes private messages to specific recipients.

In this step, get ready to:
  • Replace the anonymous client list with a username-to-stream map on the server.
  • Implement a username handshake so the server knows who each client is.
  • Add private messaging with a /pm command that routes messages to a single recipient.
Update the server to handle usernames and private messages

Right now, the server stores a flat list of output streams with no idea which stream belongs to which person. To support usernames and private messaging, you need a protocol.

What is a protocol?

A protocol is an agreement between client and server about the format and order of messages. Your protocol will have two rules:

  • Handshake: the very first message a client sends after connecting is its username.
  • Commands: any message starting with /pm username message is private. Everything else is broadcast.

The server currently stores clients in an ArrayList. You will replace that with a HashMap that maps each username to its output stream. This lets the server look up a specific user when routing a private message.

  • In ServerController.java, find these two import lines:
import java.util.ArrayList;
import java.util.List;
  • Replace them with these imports:
import java.util.HashMap;
import java.util.Map;
  • Next, find the clients field declaration:
private final List<DataOutputStream> clients = new ArrayList<>();
  • Replace it with a Map that stores usernames as keys:
private final Map<String, DataOutputStream> clientMap = new HashMap<>();

Why a HashMap?

A HashMap lets you look up a value by its key in constant time. When a client sends /pm Alice hello, the server needs to instantly find Alice's output stream. A list would require searching every entry.

Now you will rewrite the handleClient() method. The new version reads the username first, then enters a loop that checks whether each message is a private message or a broadcast.

  • Replace the entire handleClient() method with this code:
private void handleClient(Socket clientSocket) {
    String username = null;
    DataOutputStream out = null;
    try {
        DataInputStream in = new DataInputStream(clientSocket.getInputStream());
        out = new DataOutputStream(clientSocket.getOutputStream());

        // First message from the client is always the username
        username = in.readUTF();
        clientMap.put(username, out);
        appendMessage(username + " has joined.");
        broadcast(username + " has joined the chat.");

        while (true) {
            String message = in.readUTF();
            if (message.startsWith("/pm ")) {
                // Parse: /pm targetUsername privateMessage
                String[] parts = message.split(" ", 3);
                if (parts.length == 3) {
                    String targetUsername = parts[1];
                    String privateMessage = parts[2];
                    DataOutputStream targetOut = clientMap.get(targetUsername);
                    if (targetOut != null) {
                        // Send to the target
                        targetOut.writeUTF("[PM from " + username + "]: " + privateMessage);
                        targetOut.flush();
                        // Confirm back to the sender
                        out.writeUTF("[PM to " + targetUsername + "]: " + privateMessage);
                        out.flush();
                    } else {
                        // Target user does not exist
                        out.writeUTF("User '" + targetUsername + "' not found.");
                        out.flush();
                    }
                }
            } else {
                // Regular broadcast message
                appendMessage(username + ": " + message);
                broadcast(username + ": " + message);
            }
        }

    } catch (IOException e) {
        // Client disconnected - clean up
        if (username != null) {
            clientMap.remove(username);
            appendMessage(username + " has disconnected.");
            broadcast(username + " has left the chat.");
        }
    }
}

What does this code do?

This method now handles three responsibilities:

  • Handshake: the first readUTF() call reads the client's username and stores it in the map.
  • Routing: inside the loop, messages starting with /pm are split into three parts (the command, the target username, and the message body). The server looks up the target's stream and sends directly to them.
  • Cleanup: when the client disconnects (IOException), the server removes them from the map and notifies everyone else.

The broadcast() and sendMessage() methods also need updates to work with the new map.

  • Replace the existing broadcast() and sendMessage() methods with these updated versions:
private void broadcast(String message) {
    // Send to all connected clients using the map values
    for (DataOutputStream out : clientMap.values()) {
        try {
            out.writeUTF(message);
            out.flush();
        } catch (IOException e) {
            // skip disconnected client
        }
    }
}

@FXML
public void sendMessage() {
    String msg = server_text_field.getText().trim();
    if (msg.isEmpty()) return;

    // Prefix with "Server:" so clients know who sent it
    broadcast("Server: " + msg);
    appendMessage("Server (You): " + msg);
    server_text_field.clear();
}

What changed here?

  • broadcast() now iterates clientMap.values() instead of the old clients list.
  • sendMessage() prepends "Server: " to broadcasts so clients can see the message came from the server.
  • Finally, in the startServer() method, find and delete this line:
appendMessage("Client connected: " + clientSocket.getRemoteSocketAddress());

The join notification now happens inside handleClient() where you know the username, so this old anonymous notification is no longer needed.

  • Save ServerController.java.

Seeing red underlines after your changes?

  • Make sure you removed both the ArrayList and List imports and added HashMap and Map instead.
  • Check that the field declaration uses Map<String, DataOutputStream> and not the old List<DataOutputStream> type.
  • Confirm you deleted the entire old handleClient() method before pasting the new one. Having two copies causes a duplicate method error.

Help me fix compilation errors in my ServerController

✔️ Awesome, I've got everything!

Double check you have saved ServerController.java before continuing.

ⓧ I'd like to double check the full code

package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.ResourceBundle;

public class ServerController implements Initializable {

    @FXML private Button server_send_btn;
    @FXML private TextArea server_text_area;
    @FXML private TextField server_text_field;

    private final Map<String, DataOutputStream> clientMap = new HashMap<>();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        server_text_area.setEditable(false);
        server_text_field.setDisable(true);
        server_send_btn.setDisable(true);
        startServer();
    }

    private void startServer() {
        Thread serverThread = new Thread(() -> {
            try {
                ServerSocket serverSocket = new ServerSocket(3000);
                appendMessage("Server started. Waiting for clients...");

                Platform.runLater(() -> {
                    server_text_field.setDisable(false);
                    server_send_btn.setDisable(false);
                });

                while (true) {
                    Socket clientSocket = serverSocket.accept();
                    Thread clientThread = new Thread(() -> handleClient(clientSocket));
                    clientThread.setDaemon(true);
                    clientThread.start();
                }

            } catch (IOException e) {
                appendMessage("Server error: " + e.getMessage());
            }
        });
        serverThread.setDaemon(true);
        serverThread.start();
    }

    private void handleClient(Socket clientSocket) {
        String username = null;
        DataOutputStream out = null;
        try {
            DataInputStream in = new DataInputStream(clientSocket.getInputStream());
            out = new DataOutputStream(clientSocket.getOutputStream());

            username = in.readUTF();
            clientMap.put(username, out);
            appendMessage(username + " has joined.");
            broadcast(username + " has joined the chat.");

            while (true) {
                String message = in.readUTF();
                if (message.startsWith("/pm ")) {
                    String[] parts = message.split(" ", 3);
                    if (parts.length == 3) {
                        String targetUsername = parts[1];
                        String privateMessage = parts[2];
                        DataOutputStream targetOut = clientMap.get(targetUsername);
                        if (targetOut != null) {
                            targetOut.writeUTF("[PM from " + username + "]: " + privateMessage);
                            targetOut.flush();
                            out.writeUTF("[PM to " + targetUsername + "]: " + privateMessage);
                            out.flush();
                        } else {
                            out.writeUTF("User '" + targetUsername + "' not found.");
                            out.flush();
                        }
                    }
                } else {
                    appendMessage(username + ": " + message);
                    broadcast(username + ": " + message);
                }
            }

        } catch (IOException e) {
            if (username != null) {
                clientMap.remove(username);
                appendMessage(username + " has disconnected.");
                broadcast(username + " has left the chat.");
            }
        }
    }

    private void broadcast(String message) {
        for (DataOutputStream out : clientMap.values()) {
            try {
                out.writeUTF(message);
                out.flush();
            } catch (IOException e) {
                // skip disconnected client
            }
        }
    }

    @FXML
    public void sendMessage() {
        String msg = server_text_field.getText().trim();
        if (msg.isEmpty()) return;

        broadcast("Server: " + msg);
        appendMessage("Server (You): " + msg);
        server_text_field.clear();
    }

    private void appendMessage(String message) {
        Platform.runLater(() -> server_text_area.appendText(message + "\n"));
    }
}
Update the client to send usernames and handle private messages

The client side needs three changes: accept a username from the outside, send it to the server immediately after connecting, and skip the local echo when sending a private message (since the server sends back a confirmation instead).

Previously, initialize() called setClient() directly. Now the connection must wait until a username is assigned, so you will move the trigger into a new setUsername() method.

  • In ClientController.java, add a new field below the existing dataInputStream declaration:
private String username;
  • Replace the initialize() method with an empty body, and add a new setUsername() method below it:
@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
}

public void setUsername(String username) {
    this.username = username;
    setClient();
}

Why move the connection out of initialize?

The initialize() method runs automatically when the FXML loads. At that point, no username has been assigned yet. By moving the connection trigger to setUsername(), the client only connects once it knows who it is.

Next, the setClient() method needs to send the username as the very first message after connecting. The reading loop also simplifies because the server now includes usernames in broadcast messages.

  • Replace the entire setClient() method with this updated version:
private void setClient() {
    try {
        remoteSocket = new Socket("127.0.0.1", 3000);
        dataOutputStream = new DataOutputStream(remoteSocket.getOutputStream());
        dataInputStream = new DataInputStream(remoteSocket.getInputStream());

        // Send username as the first message (handshake)
        dataOutputStream.writeUTF(username);
        dataOutputStream.flush();

        new Thread(() -> {
            while (true) {
                try {
                    String message = dataInputStream.readUTF();
                    // Server now includes usernames in messages
                    toChat(message);
                } catch (IOException e) {
                    System.out.println("Server disconnected.");
                    break;
                }
            }
        }).start();

    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

What changed in setClient?

  • The handshake line dataOutputStream.writeUTF(username) sends the username immediately after connecting. The server reads this as the first message.
  • The reading loop no longer prepends "Server: " because the server already formats messages with the sender's name.

The sendMessage() method needs a small adjustment. When the user types a /pm command, you should not echo "You: /pm ..." locally because the server sends back a formatted confirmation instead.

  • Replace the sendMessage() method with this updated version:
@FXML
public void sendMessage() {
    String msg = client_text_field.getText().trim();
    if (msg.isEmpty()) {
        return;
    }
    try {
        dataOutputStream.writeUTF(msg);
        dataOutputStream.flush();
        // Only echo locally for regular messages, not /pm
        if (!msg.startsWith("/pm ")) {
            toChat("You: " + msg);
        }
        client_text_field.clear();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

Why skip the local echo for /pm?

For private messages, the server sends back a confirmation like [PM to Client2]: hello. If you also echoed the raw command locally, the user would see duplicate output.

  • Save ClientController.java.

Seeing errors in ClientController?

  • Make sure the initialize() method body is completely empty. It should not call setClient() anymore.
  • Verify that setUsername() is public. The ClientInitializer class calls it from outside.

Help me debug my ClientController changes

✔️ Awesome, I've got everything!

Double check you have saved ClientController.java before continuing.

ⓧ I'd like to double check the full code

package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.URL;
import java.util.ResourceBundle;

public class ClientController implements Initializable {

    @FXML private Button client_send_btn;
    @FXML private TextArea client_text_area;
    @FXML private TextField client_text_field;

    private Socket remoteSocket;
    private DataOutputStream dataOutputStream;
    private DataInputStream dataInputStream;
    private String username;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
    }

    public void setUsername(String username) {
        this.username = username;
        setClient();
    }

    private void setClient() {
        try {
            remoteSocket = new Socket("127.0.0.1", 3000);
            dataOutputStream = new DataOutputStream(remoteSocket.getOutputStream());
            dataInputStream = new DataInputStream(remoteSocket.getInputStream());

            dataOutputStream.writeUTF(username);
            dataOutputStream.flush();

            new Thread(() -> {
                while (true) {
                    try {
                        String message = dataInputStream.readUTF();
                        toChat(message);
                    } catch (IOException e) {
                        System.out.println("Server disconnected.");
                        break;
                    }
                }
            }).start();

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @FXML
    public void sendMessage() {
        String msg = client_text_field.getText().trim();
        if (msg.isEmpty()) {
            return;
        }
        try {
            dataOutputStream.writeUTF(msg);
            dataOutputStream.flush();
            if (!msg.startsWith("/pm ")) {
                toChat("You: " + msg);
            }
            client_text_field.clear();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void toChat(String txt) {
        Platform.runLater(() -> client_text_area.appendText(txt + "\n"));
    }
}
Update ClientInitializer to assign usernames and test the system

The ClientInitializer creates all five client windows. It needs to pass a unique username to each one. Since you are using FXMLLoader in instance mode (not the static FXMLLoader.load()), you can call loader.getController() to get a reference to the controller after loading.

  • In ClientInitializer.java, find the start() method and locate this section inside the for loop:
clientStage.setScene(new Scene(loader.load()));
clientStage.show();
  • Add two lines between them to get the controller and assign the username:
clientStage.setScene(new Scene(loader.load()));
ClientController controller = loader.getController();
controller.setUsername("Client" + (i + 1));
clientStage.show();

What does this code do?

After the FXML loads, loader.getController() returns the ClientController instance that JavaFX created for that window. Calling setUsername() assigns a name like "Client1" and triggers the server connection.

  • Save ClientInitializer.java.

Time to test the full system with usernames and private messaging.

  • Run ServerInitializer first (right-click the class and select Run).
  • Run ClientInitializer (right-click the class and select Run).
  • Type a message in any client window and click Send. The server text area should show the sender's username (e.g., "Client1: hello").
  • In one of the client windows, type /pm Client2 this is private and click Send.

You should see "[PM to Client2]: this is private" in the sender's window and "[PM from Client1]: this is private" in Client2's window only.

Private messages not arriving?

  • Usernames are case-sensitive. Type /pm Client2 (capital C) exactly as the window title shows.
  • Make sure you started the server before the clients. If clients connect before the server is running, they crash on startup.
  • Check that the /pm command has exactly three space-separated parts: the command, the target username, and the message. Typing /pm Client2 without a message does nothing.

Help me debug private messaging in my chat app

✔️ Awesome, I've got everything!

Double check you have saved all three files before continuing.

ⓧ I'd like to double check the full code

package org.example;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import java.io.IOException;

public class ClientInitializer extends Application {

    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage stage) throws IOException {
        for (int i = 0; i < 5; i++) {
            FXMLLoader loader = new FXMLLoader(getClass().getResource("/client.fxml"));
            Stage clientStage = new Stage();
            clientStage.setTitle("Client " + (i + 1));
            clientStage.setScene(new Scene(loader.load()));
            ClientController controller = loader.getController();
            controller.setUsername("Client" + (i + 1));
            clientStage.show();
        }
    }
}

Your chat system now identifies every user by name and can route messages privately between two people. Next up, you will add emoji support so clients can send expressive Unicode characters alongside their text.

Send Emojis and Binary Files

Your chat system now supports usernames and private messaging. Clients can identify themselves and send direct messages to specific users. The next frontier is rich media. Real chat apps send emojis, images, files, and videos alongside text.

In this step, you will add emoji support (which turns out to be surprisingly simple) and build a custom binary protocol for transferring files, images, and videos between clients.

In this step, get ready to:
  • Send and receive emoji characters between clients.
  • Design a binary message framing protocol for files and media.
  • Implement file, image, and video transfer between clients.
Send emojis between clients

Java Strings are stored internally as UTF-16. The writeUTF() method uses modified UTF-8 encoding, which supports the full Unicode range including emoji codepoints. This means emoji characters travel across your sockets without any protocol changes.

The only consideration is font rendering. You need a font that includes emoji glyphs, like Segoe UI Emoji on Windows.

  • Update client.fxml to add an Emoji button and a Send File button by replacing its entire contents with this code:
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane prefHeight="450.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.ClientController">
    <children>
        <TextArea fx:id="client_text_area" editable="false" layoutX="14.0" layoutY="14.0" prefHeight="320.0" prefWidth="572.0" />
        <TextField fx:id="client_text_field" layoutX="14.0" layoutY="350.0" prefHeight="30.0" prefWidth="370.0" />
        <Button fx:id="client_send_btn" layoutX="400.0" layoutY="350.0" mnemonicParsing="false" onAction="#sendMessage" prefHeight="30.0" prefWidth="86.0" text="Send" />
        <Button fx:id="emoji_btn" layoutX="14.0" layoutY="395.0" mnemonicParsing="false" onAction="#showEmojiPicker" prefHeight="30.0" prefWidth="86.0" text="Emoji" />
        <Button fx:id="send_file_btn" layoutX="110.0" layoutY="395.0" mnemonicParsing="false" onAction="#sendFile" prefHeight="30.0" prefWidth="86.0" text="Send File" />
    </children>
</AnchorPane>

What changed in the FXML?

The pane height increased from 400 to 450 to make room for a second row of buttons. The text field width decreased from 470 to 370 to fit the adjusted layout.

Two new buttons appear below the text field: an Emoji button that calls #showEmojiPicker and a Send File button that calls #sendFile.

  • Save client.fxml.

Seeing FXML parse errors?

Make sure every fx:id matches the field name in ClientController.java exactly. You will add the matching Java fields in the next chunk.

help me fix FXML parsing errors in my JavaFX chat project

  • In ClientController.java, add the new FXML field declarations and the emoji font style. Find the existing FXML fields and add the two new button references below them:
@FXML private Button emoji_btn;
@FXML private Button send_file_btn;
  • In the initialize() method, add a line to set the TextArea font to one that supports emoji glyphs:
@Override
public void initialize(URL url, ResourceBundle resourceBundle) {
    client_text_area.setStyle("-fx-font-family: 'Segoe UI Emoji';");
}
  • Add the showEmojiPicker() method to ClientController.java. Place it after the sendMessage() method:
@FXML
public void showEmojiPicker() {
    String[] emojis = {"😀", "😂", "\u2764\uFE0F", "👍", "🎉", "🔥", "😎", "🤔"};
    StringBuilder picker = new StringBuilder();
    for (String e : emojis) picker.append(e).append(" ");
    client_text_field.setText(client_text_field.getText() + emojis[0]);
}

What does this code do?

The -fx-font-family: 'Segoe UI Emoji' style ensures the TextArea can render emoji glyphs. On macOS, the system font already supports emojis natively.

The showEmojiPicker() method inserts an emoji character directly into the text field. Since emojis are just Unicode characters, writeUTF() transmits them correctly over the socket with no extra encoding work.

  • Save ClientController.java.

Emojis showing as squares?

This usually means the font does not include emoji glyphs. On Windows, verify the style is set to Segoe UI Emoji. On Linux, you may need to install a package like fonts-noto-color-emoji.

help me fix emoji rendering in my JavaFX TextArea

✔️ Awesome, I've got everything!

Great. Make sure you have saved client.fxml before moving on to the next substep.

ⓧ I'd like to double check the full code

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane prefHeight="450.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.ClientController">
    <children>
        <TextArea fx:id="client_text_area" editable="false" layoutX="14.0" layoutY="14.0" prefHeight="320.0" prefWidth="572.0" />
        <TextField fx:id="client_text_field" layoutX="14.0" layoutY="350.0" prefHeight="30.0" prefWidth="370.0" />
        <Button fx:id="client_send_btn" layoutX="400.0" layoutY="350.0" mnemonicParsing="false" onAction="#sendMessage" prefHeight="30.0" prefWidth="86.0" text="Send" />
        <Button fx:id="emoji_btn" layoutX="14.0" layoutY="395.0" mnemonicParsing="false" onAction="#showEmojiPicker" prefHeight="30.0" prefWidth="86.0" text="Emoji" />
        <Button fx:id="send_file_btn" layoutX="110.0" layoutY="395.0" mnemonicParsing="false" onAction="#sendFile" prefHeight="30.0" prefWidth="86.0" text="Send File" />
    </children>
</AnchorPane>
Design and implement the binary file transfer protocol on the server

The writeUTF() method has a hard limit. It can only handle strings whose modified UTF-8 encoding fits within 65,535 bytes. That means it cannot transfer binary files like images or videos.

To send binary data, you need a custom protocol. The concept is called message framing. Every message starts with a type byte that tells the receiver what kind of data follows.

How does the binary protocol work?

Each message begins with a single byte indicating its type:

  • Type 0 = text message. Followed by a writeUTF() string (same as before).
  • Type 1 = generic file. Followed by: filename length (int), filename (bytes), file size (long), raw file bytes.
  • Type 2 = image. Same frame as type 1, but the client knows to display it inline.
  • Type 3 = video. Same frame as type 1, but the client can offer media playback.
  • Add the javafx-media dependency to your pom.xml. Find the <dependencies> section and add this dependency after the existing javafx-fxml dependency:
<dependency>
    <groupId>org.openjfx</groupId>
    <artifactId>javafx-media</artifactId>
    <version>21</version>
</dependency>

Why add javafx-media?

This module provides the MediaPlayer class for video playback. Even if you only save video files to disk for now, having the dependency available means you can extend playback later.

  • Save pom.xml and let IntelliJ reimport the Maven dependencies. You should see the javafx-media library appear in the External Libraries section of your project.

Now you need to rewrite the server to read the type byte first before processing each message. This is a significant change to ServerController.java.

  • In ServerController.java, replace the entire handleClient() method with this updated version that reads a message type byte first:
private void handleClient(Socket clientSocket) {
    String username = null;
    DataOutputStream out = null;
    try {
        DataInputStream in = new DataInputStream(clientSocket.getInputStream());
        out = new DataOutputStream(clientSocket.getOutputStream());

        // First message is always the username (still plain UTF)
        username = in.readUTF();
        clientMap.put(username, out);
        appendMessage(username + " has joined.");
        broadcast(username + " has joined the chat.");

        while (true) {
            // Read the message type byte to determine what follows
            byte messageType = in.readByte();

            if (messageType == 0) {
                // Text message
                String message = in.readUTF();
                if (message.startsWith("/pm ")) {
                    String[] parts = message.split(" ", 3);
                    if (parts.length == 3) {
                        String targetUsername = parts[1];
                        String privateMessage = parts[2];
                        DataOutputStream targetOut = clientMap.get(targetUsername);
                        if (targetOut != null) {
                            targetOut.writeByte(0);
                            targetOut.writeUTF("[PM from " + username + "]: " + privateMessage);
                            targetOut.flush();
                            out.writeByte(0);
                            out.writeUTF("[PM to " + targetUsername + "]: " + privateMessage);
                            out.flush();
                        } else {
                            out.writeByte(0);
                            out.writeUTF("User '" + targetUsername + "' not found.");
                            out.flush();
                        }
                    }
                } else {
                    appendMessage(username + ": " + message);
                    broadcastText(username + ": " + message);
                }
            } else {
                // Binary file/image/video
                int filenameLen = in.readInt();
                byte[] filenameBytes = in.readNBytes(filenameLen);
                String filename = new String(filenameBytes);
                long fileSize = in.readLong();
                byte[] fileData = in.readNBytes((int) fileSize);

                appendMessage(username + " sent a file: " + filename);
                broadcastFile(username, messageType, filename, fileData);
            }
        }

    } catch (IOException e) {
        if (username != null) {
            clientMap.remove(username);
            appendMessage(username + " has disconnected.");
            broadcastText(username + " has left the chat.");
        }
    }
}

What changed in handleClient?

The first message (username) is still read with plain readUTF() since it happens before the protocol loop begins.

Inside the loop, in.readByte() reads the type byte. If it is 0, the server reads the text message with readUTF(). For any other type, it reads the binary frame: filename length, filename bytes, file size, and raw file data.

All outgoing text messages now include writeByte(0) before writeUTF() so the client knows it is receiving a text frame.

  • Now replace the existing broadcast() method and add the new broadcastText() and broadcastFile() methods in ServerController.java:
private void broadcastText(String message) {
    for (DataOutputStream out : clientMap.values()) {
        try {
            out.writeByte(0);
            out.writeUTF(message);
            out.flush();
        } catch (IOException e) {
            // skip disconnected client
        }
    }
}

private void broadcastFile(String sender, byte type, String filename, byte[] data) {
    for (DataOutputStream out : clientMap.values()) {
        try {
            out.writeByte(type);
            byte[] filenameBytes = (sender + "/" + filename).getBytes();
            out.writeInt(filenameBytes.length);
            out.write(filenameBytes);
            out.writeLong(data.length);
            out.write(data);
            out.flush();
        } catch (IOException e) {
            // skip disconnected client
        }
    }
}

private void broadcast(String message) {
    broadcastText(message);
}

What do these methods do?

  • broadcastText() sends a type 0 byte followed by the UTF string to every connected client.
  • broadcastFile() sends the type byte, prepends the sender's username to the filename, then streams the entire binary frame to each client.
  • broadcast() is kept as a convenience wrapper that calls broadcastText().
  • Save ServerController.java.

Compile errors in ServerController?

If you see errors about broadcastText or broadcastFile not being found, make sure you pasted both methods inside the class body but outside any other method.

help me fix compile errors in my ServerController after adding broadcastText and broadcastFile

✔️ Awesome, I've got everything!

Great. Make sure you have saved both pom.xml and ServerController.java.

ⓧ I'd like to double check the full code

package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.ResourceBundle;

public class ServerController implements Initializable {

    @FXML private Button server_send_btn;
    @FXML private TextArea server_text_area;
    @FXML private TextField server_text_field;

    private final Map<String, DataOutputStream> clientMap = new HashMap<>();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        server_text_area.setEditable(false);
        server_text_field.setDisable(true);
        server_send_btn.setDisable(true);
        startServer();
    }

    private void startServer() {
        Thread serverThread = new Thread(() -> {
            try {
                ServerSocket serverSocket = new ServerSocket(3000);
                appendMessage("Server started. Waiting for clients...");

                Platform.runLater(() -> {
                    server_text_field.setDisable(false);
                    server_send_btn.setDisable(false);
                });

                while (true) {
                    Socket clientSocket = serverSocket.accept();
                    Thread clientThread = new Thread(() -> handleClient(clientSocket));
                    clientThread.setDaemon(true);
                    clientThread.start();
                }

            } catch (IOException e) {
                appendMessage("Server error: " + e.getMessage());
            }
        });
        serverThread.setDaemon(true);
        serverThread.start();
    }

    private void handleClient(Socket clientSocket) {
        String username = null;
        DataOutputStream out = null;
        try {
            DataInputStream in = new DataInputStream(clientSocket.getInputStream());
            out = new DataOutputStream(clientSocket.getOutputStream());

            username = in.readUTF();
            clientMap.put(username, out);
            appendMessage(username + " has joined.");
            broadcast(username + " has joined the chat.");

            while (true) {
                byte messageType = in.readByte();

                if (messageType == 0) {
                    // Text message
                    String message = in.readUTF();
                    if (message.startsWith("/pm ")) {
                        String[] parts = message.split(" ", 3);
                        if (parts.length == 3) {
                            String targetUsername = parts[1];
                            String privateMessage = parts[2];
                            DataOutputStream targetOut = clientMap.get(targetUsername);
                            if (targetOut != null) {
                                targetOut.writeByte(0);
                                targetOut.writeUTF("[PM from " + username + "]: " + privateMessage);
                                targetOut.flush();
                                out.writeByte(0);
                                out.writeUTF("[PM to " + targetUsername + "]: " + privateMessage);
                                out.flush();
                            } else {
                                out.writeByte(0);
                                out.writeUTF("User '" + targetUsername + "' not found.");
                                out.flush();
                            }
                        }
                    } else {
                        appendMessage(username + ": " + message);
                        broadcastText(username + ": " + message);
                    }
                } else {
                    // Binary file/image/video
                    int filenameLen = in.readInt();
                    byte[] filenameBytes = in.readNBytes(filenameLen);
                    String filename = new String(filenameBytes);
                    long fileSize = in.readLong();
                    byte[] fileData = in.readNBytes((int) fileSize);

                    appendMessage(username + " sent a file: " + filename);
                    broadcastFile(username, messageType, filename, fileData);
                }
            }

        } catch (IOException e) {
            if (username != null) {
                clientMap.remove(username);
                appendMessage(username + " has disconnected.");
                broadcastText(username + " has left the chat.");
            }
        }
    }

    private void broadcastText(String message) {
        for (DataOutputStream out : clientMap.values()) {
            try {
                out.writeByte(0);
                out.writeUTF(message);
                out.flush();
            } catch (IOException e) {
                // skip disconnected client
            }
        }
    }

    private void broadcastFile(String sender, byte type, String filename, byte[] data) {
        for (DataOutputStream out : clientMap.values()) {
            try {
                out.writeByte(type);
                byte[] filenameBytes = (sender + "/" + filename).getBytes();
                out.writeInt(filenameBytes.length);
                out.write(filenameBytes);
                out.writeLong(data.length);
                out.write(data);
                out.flush();
            } catch (IOException e) {
                // skip disconnected client
            }
        }
    }

    private void broadcast(String message) {
        broadcastText(message);
    }

    @FXML
    public void sendMessage() {
        String msg = server_text_field.getText().trim();
        if (msg.isEmpty()) return;

        broadcastText("Server: " + msg);
        appendMessage("Server (You): " + msg);
        server_text_field.clear();
    }

    private void appendMessage(String message) {
        Platform.runLater(() -> server_text_area.appendText(message + "\n"));
    }
}
Update the client for binary transfer and test the system

The server now speaks the new binary protocol. You need to update the client to match. The client must send a type byte before every text message, read the type byte on incoming messages, and handle binary file frames.

  • In ClientController.java, add these imports at the top of the file alongside the existing imports:
import javafx.stage.FileChooser;

import java.io.File;
import java.nio.file.Files;
  • In ClientController.java, find the sendMessage() method and replace it with this updated version that sends the type byte before the text:
@FXML
public void sendMessage() {
    String msg = client_text_field.getText().trim();
    if (msg.isEmpty()) {
        return;
    }
    try {
        // Send type byte 0 for text messages
        dataOutputStream.writeByte(0);
        dataOutputStream.writeUTF(msg);
        dataOutputStream.flush();
        if (!msg.startsWith("/pm ")) {
            toChat("You: " + msg);
        }
        client_text_field.clear();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

What changed in sendMessage?

The only addition is dataOutputStream.writeByte(0) before writeUTF(). This tells the server that a text message follows. Without this byte, the server would misinterpret the first byte of the UTF string as a message type.

  • Now replace the reading loop inside setClient(). Find the new Thread(() -> { block and replace it with this version that reads the type byte first:
new Thread(() -> {
    while (true) {
        try {
            byte messageType = dataInputStream.readByte();
            if (messageType == 0) {
                String message = dataInputStream.readUTF();
                toChat(message);
            } else {
                int filenameLen = dataInputStream.readInt();
                byte[] filenameBytes = dataInputStream.readNBytes(filenameLen);
                String filename = new String(filenameBytes);
                long fileSize = dataInputStream.readLong();
                byte[] fileData = dataInputStream.readNBytes((int) fileSize);

                File outFile = new File(System.getProperty("user.home") + "/Downloads/" + filename.replace("/", "_"));
                Files.write(outFile.toPath(), fileData);
                toChat("[File received: " + filename + " saved to " + outFile.getAbsolutePath() + "]");
            }
        } catch (IOException e) {
            System.out.println("Server disconnected.");
            break;
        }
    }
}).start();

What does the binary receive logic do?

When the type byte is not 0, the client reads the binary frame in order: filename length, filename bytes, file size, and then exactly that many bytes of file data.

The received file is saved to the user's Downloads folder. The filename includes the sender's username (separated by a slash, replaced with underscore for filesystem safety).

  • Finally, add the sendFile() method to ClientController.java. Place it after the showEmojiPicker() method:
@FXML
public void sendFile() {
    FileChooser fileChooser = new FileChooser();
    fileChooser.setTitle("Select File to Send");
    File file = fileChooser.showOpenDialog(send_file_btn.getScene().getWindow());
    if (file == null) return;

    new Thread(() -> {
        try {
            byte[] fileData = Files.readAllBytes(file.toPath());
            String filename = file.getName();
            String ext = filename.toLowerCase();

            // Determine type based on file extension
            byte type;
            if (ext.endsWith(".png") || ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".gif")) {
                type = 2;
            } else if (ext.endsWith(".mp4") || ext.endsWith(".avi") || ext.endsWith(".mov")) {
                type = 3;
            } else {
                type = 1;
            }

            // Send binary frame: type, filename, size, data
            byte[] filenameBytes = filename.getBytes();
            dataOutputStream.writeByte(type);
            dataOutputStream.writeInt(filenameBytes.length);
            dataOutputStream.write(filenameBytes);
            dataOutputStream.writeLong(fileData.length);
            dataOutputStream.write(fileData);
            dataOutputStream.flush();

            toChat("[You sent: " + filename + "]");
        } catch (IOException e) {
            toChat("[Error sending file: " + e.getMessage() + "]");
        }
    }).start();
}

What does sendFile do?

FileChooser opens a native file dialog. The method blocks until the user picks a file or cancels. If cancelled, showOpenDialog() returns null and the method exits early.

The file extension determines the type byte (1 for generic files, 2 for images, 3 for videos). The file is read into a byte array, then the binary frame is written: type byte, filename length, filename bytes, file size as a long, and the raw file bytes.

The entire operation runs on a background thread so the UI stays responsive during large file transfers.

  • Save ClientController.java.
  • Run the server first (right-click ServerInitializer and select Run).
  • Run the client (right-click ClientInitializer and select Run).
  • In one client window, click Emoji to insert an emoji, then click Send. The emoji should appear in all other client windows.
  • In another client window, click Send File, pick any image or file from your computer, and send it. The receiving clients should show a "File received" message with the saved path.

Connection refused or protocol errors?

Make sure the server is running before starting clients. If you see EOFException immediately, the client and server protocols are out of sync. Double-check that both sendMessage() in the client writes writeByte(0) before writeUTF(), and that the server reads readByte() first in the loop.

help me debug protocol mismatch errors between my chat server and client

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files before testing.

ⓧ I'd like to double check the full code

package org.example;

import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.stage.FileChooser;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.Socket;
import java.net.URL;
import java.nio.file.Files;
import java.util.ResourceBundle;

public class ClientController implements Initializable {

    @FXML private Button client_send_btn;
    @FXML private TextArea client_text_area;
    @FXML private TextField client_text_field;
    @FXML private Button emoji_btn;
    @FXML private Button send_file_btn;

    private Socket remoteSocket;
    private DataOutputStream dataOutputStream;
    private DataInputStream dataInputStream;
    private String username;

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        client_text_area.setStyle("-fx-font-family: 'Segoe UI Emoji';");
    }

    public void setUsername(String username) {
        this.username = username;
        setClient();
    }

    private void setClient() {
        try {
            remoteSocket = new Socket("127.0.0.1", 3000);
            dataOutputStream = new DataOutputStream(remoteSocket.getOutputStream());
            dataInputStream = new DataInputStream(remoteSocket.getInputStream());

            dataOutputStream.writeUTF(username);
            dataOutputStream.flush();

            new Thread(() -> {
                while (true) {
                    try {
                        byte messageType = dataInputStream.readByte();
                        if (messageType == 0) {
                            String message = dataInputStream.readUTF();
                            toChat(message);
                        } else {
                            int filenameLen = dataInputStream.readInt();
                            byte[] filenameBytes = dataInputStream.readNBytes(filenameLen);
                            String filename = new String(filenameBytes);
                            long fileSize = dataInputStream.readLong();
                            byte[] fileData = dataInputStream.readNBytes((int) fileSize);

                            File outFile = new File(System.getProperty("user.home") + "/Downloads/" + filename.replace("/", "_"));
                            Files.write(outFile.toPath(), fileData);
                            toChat("[File received: " + filename + " saved to " + outFile.getAbsolutePath() + "]");
                        }
                    } catch (IOException e) {
                        System.out.println("Server disconnected.");
                        break;
                    }
                }
            }).start();

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @FXML
    public void sendMessage() {
        String msg = client_text_field.getText().trim();
        if (msg.isEmpty()) {
            return;
        }
        try {
            dataOutputStream.writeByte(0);
            dataOutputStream.writeUTF(msg);
            dataOutputStream.flush();
            if (!msg.startsWith("/pm ")) {
                toChat("You: " + msg);
            }
            client_text_field.clear();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @FXML
    public void showEmojiPicker() {
        String[] emojis = {"😀", "😂", "\u2764\uFE0F", "👍", "🎉", "🔥", "😎", "🤔"};
        StringBuilder picker = new StringBuilder();
        for (String e : emojis) picker.append(e).append(" ");
        client_text_field.setText(client_text_field.getText() + emojis[0]);
    }

    @FXML
    public void sendFile() {
        FileChooser fileChooser = new FileChooser();
        fileChooser.setTitle("Select File to Send");
        File file = fileChooser.showOpenDialog(send_file_btn.getScene().getWindow());
        if (file == null) return;

        new Thread(() -> {
            try {
                byte[] fileData = Files.readAllBytes(file.toPath());
                String filename = file.getName();
                String ext = filename.toLowerCase();

                byte type;
                if (ext.endsWith(".png") || ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".gif")) {
                    type = 2;
                } else if (ext.endsWith(".mp4") || ext.endsWith(".avi") || ext.endsWith(".mov")) {
                    type = 3;
                } else {
                    type = 1;
                }

                byte[] filenameBytes = filename.getBytes();
                dataOutputStream.writeByte(type);
                dataOutputStream.writeInt(filenameBytes.length);
                dataOutputStream.write(filenameBytes);
                dataOutputStream.writeLong(fileData.length);
                dataOutputStream.write(fileData);
                dataOutputStream.flush();

                toChat("[You sent: " + filename + "]");
            } catch (IOException e) {
                toChat("[Error sending file: " + e.getMessage() + "]");
            }
        }).start();
    }

    private void toChat(String txt) {
        Platform.runLater(() -> client_text_area.appendText(txt + "\n"));
    }
}

Your chat system now handles text, emojis, and binary file transfers. You have a working custom protocol that frames every message with a type byte so both sides know exactly what to expect. Next up, you will take on the Secret Mission and add a typing indicator.

Secret mission

Add a Typing Indicator

Ever noticed how messaging apps show "Username is typing..." when someone is composing a message? In this secret mission, you'll implement that same feature using protocol commands and a debounce timer. This combines everything you've learned about protocol design, threading, and Platform.runLater into one elegant feature.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for future reference, or delete them entirely.

Resources you used:

  • Maven project folder (JavaSocketProgramming) on your Desktop.
  • Java source files: ServerInitializer.java, ServerController.java, ClientInitializer.java, ClientController.java.
  • FXML layout files: server.fxml and client.fxml.
  • IntelliJ IDEA run configurations for ServerInitializer and ClientInitializer.
  • Maven dependencies cached in your local .m2 repository.

Keep everything running

No action needed. Choose this if you plan to continue building on your chat application or want to revisit the code later.

  • Your project folder stays on your Desktop, ready to open in IntelliJ IDEA whenever you want.
  • Your run configurations in IntelliJ will be saved automatically.

Pause - I'll come back to this later

Stop any running server or client processes to free up memory. Your files stay on disk for when you return.

  • In IntelliJ IDEA, click the red square stop button in the toolbar to terminate any running server or client processes.
  • Close IntelliJ IDEA to free up memory.
  • Your project folder and all source files remain on your Desktop. Reopen the project in IntelliJ whenever you want to continue.

Delete - I don't want to use this again

Remove all project files from your machine entirely. You can always rebuild the project from scratch using this guide.

  • Stop any running processes in IntelliJ IDEA by clicking the red square stop button.
  • Close IntelliJ IDEA.
  • Delete the JavaSocketProgramming folder from your Desktop.
  • Optionally, clear the cached Maven dependencies by deleting the ~/.m2/repository/org/openjfx folder. This frees up disk space but means Maven will re-download JavaFX if you use it in a future project.

That's a Wrap!

That's a Wrap!

You did it! You built an entire multi-client chat system from the ground up using Java Sockets, multithreading, and JavaFX. You understand every line of code in this project because you wrote it yourself.

You've learned how to:

  • Built a TCP chat server that accepts multiple simultaneous client connections, each managed on its own daemon thread, with a JavaFX GUI displaying all activity in real time using Platform.runLater.
  • Designed a custom text protocol with command parsing for private messaging, username identification, and online user tracking routed through the server.
  • Implemented binary file transfer with custom protocol framing to send emojis, images, files, and videos between clients using raw byte streaming.
  • Secret Mission: Added a typing indicator with debounce logic and cross-client notifications that shows when other users are composing a message.

Ready to quiz yourself?