Real-Time Collaborative Code Editor

Build a pair programming workspace with WebSocket sync and AI hints.

Introduction

30 Second Summary

Coding interviews are better with a partner. But when you're both stuck on the same problem, the usual move is to leave the editor and Google the answer separately.

In this project, you will build a real-time collaborative coding workspace where two people type in the same editor simultaneously, see each other's changes instantly, and get AI-powered hints that escalate from gentle nudges to full solutions.

What You'll Build

You share a room code with your study partner, open the same coding problem, and start solving it together in a VS Code-quality editor while an AI tutor watches your progress and gives increasingly specific help when you ask.

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

  • A real-time collaborative editor where two people join the same room code and see each other's keystrokes appear instantly.
  • A coding interview workspace that displays a problem description alongside a full-featured Monaco Editor powered by the same engine as VS Code.
  • An AI hint system powered by Gemini that gives progressively detailed help. Each click escalates from a vague nudge to a complete solution with explanation.
  • Secret Mission: Add live cursor presence with colored labels showing each user's name.

Are there any prerequisites?

You'll need basic JavaScript and React knowledge, Node.js installed on your machine, and a free Gemini API key from Google AI Studio.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your learning.

Set Up the Project

CodeMate needs a frontend (React with a code editor) and a backend (Express for AI hints). Before building any features, you need both environments scaffolded and wired together.

In this step, you'll create the project structure, install all dependencies, and configure the development tools so both servers run with a single command.

In this step, get ready to:
  • Scaffold a React frontend and Express backend.
  • Install collaboration and AI dependencies.
  • Configure the dev server to run both processes together.
Scaffold the project and install dependencies

Your project will live in a root folder called codemate on your Desktop. Inside it, a client folder holds the React frontend and a server folder holds the Express API.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir codemate
cd codemate
  • Scaffold the React frontend using Vite by running this command:
npm create vite@latest client -- --template react

What will this command do?

This downloads the Vite scaffolding tool and creates a client folder with a ready-to-run React project inside it.

Vite may ask you to confirm installing the create-vite package. Press y then Enter to proceed. If you see a prompt asking "Install with npm and start now?", select No.

Seeing framework selection prompts on Windows?

PowerShell sometimes misinterprets the -- separator. If you see a "Select a framework" menu, cancel with Ctrl+C and run this instead:

npm create vite@latest client --- --template react

The triple dash (---) works around a known PowerShell parsing issue. Why is Vite prompting me on Windows?

You should see output ending with a message like "Done" and instructions to cd client.

  • Move into the client folder and install the collaboration and editor packages by running these commands:
cd client
npm install @monaco-editor/react monaco-editor yjs y-websocket y-monaco

What are these packages?

  • @monaco-editor/react and monaco-editor give you the same code editor engine that powers VS Code.
  • yjs is a CRDT library that handles conflict-free real-time document merging.
  • y-websocket connects Yjs documents over WebSocket for live sync.
  • y-monaco binds the Yjs document to the Monaco editor.

You should see packages being resolved and installed. When complete, npm prints a summary showing the number of packages added.

  • Move back to the project root, create the server folder, and install the server dependencies by running these commands:
cd ..
mkdir server
cd server
  • Create the server's package.json by creating a new file called package.json inside the server folder with this content:
{
  "name": "codemate-server",
  "private": true,
  "type": "module",
  "dependencies": {
    "@google/genai": "^2.13.0",
    "cors": "^2.8.5",
    "express": "^4.21.2"
  }
}

What does this file configure?

  • "type": "module" enables ES module syntax (import/export) instead of require().
  • @google/genai is Google's SDK for calling the Gemini AI API.
  • express and cors handle HTTP requests and cross-origin access.
  • Install these dependencies by running:
npm install

You should see npm resolve and install the three packages.

  • While still in the server folder, create a file called .env with your Gemini API key:
GEMINI_API_KEY=your-api-key-here

Where do I find my Gemini API key?

You should already have a key from Google AI Studio. Replace your-api-key-here with your actual key value. Keep this file private and never commit it to version control.

  • Move back to the project root and set up the root package by running these commands:
cd ..
  • Create a file called package.json in the root codemate folder with this content:
{
  "name": "codemate-root",
  "private": true,
  "scripts": {
    "dev": "concurrently \"cd client && npm run dev\" \"cd server && node server.js\"",
    "install-all": "cd client && npm install && cd ../server && npm install"
  },
  "devDependencies": {
    "concurrently": "^9.1.2"
  }
}

What does this file configure?

The dev script uses concurrently to start both the Vite dev server and the Express server in a single terminal. This means you only need one command to launch your entire app.

  • Install the root dependency by running:
npm install

You should see concurrently added to your node_modules.

Configure the Vite proxy

When your React frontend sends API requests to the Express backend, both run on different ports. A proxy tells Vite to forward any request starting with /api to the Express server automatically.

  • Open client/vite.config.js in your editor. This file was created by the Vite scaffold.
  • Replace the entire file contents with this:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3001'
    }
  }
});

What does the proxy do?

The server.proxy setting intercepts any fetch to /api from the browser and forwards it to http://localhost:3001 where your Express server runs. This avoids cross-origin issues during development.

  • Save client/vite.config.js.
Verify the setup works

Everything is installed and configured. Time to confirm the frontend dev server starts correctly.

  • Before you run this, what do you think you'll see in the terminal and in the browser?
  • Start the Vite dev server by running this from the client folder:
cd client
npm run dev

You should see Vite print a local URL: http://localhost:5173.

  • Open http://localhost:5173 in your browser.

You should see the Vite + React starter page with spinning logos. This confirms your frontend toolchain is working.

Don't see the page?

  • Make sure you're running npm run dev from inside the client folder, not the project root.
  • Check that port 5173 isn't already in use by another process.
  • If you see dependency errors, delete node_modules in the client folder and run npm install again.

My Vite dev server won't start. What should I check?

  • Stop the dev server by pressing Ctrl+C in your terminal.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here are the key files that should exist in your project. Your version numbers may differ slightly from these.

{
  "name": "codemate-root",
  "private": true,
  "scripts": {
    "dev": "concurrently \"cd client && npm run dev\" \"cd server && node server.js\"",
    "install-all": "cd client && npm install && cd ../server && npm install"
  },
  "devDependencies": {
    "concurrently": "^9.1.2"
  }
}
{
  "name": "codemate-server",
  "private": true,
  "type": "module",
  "dependencies": {
    "@google/genai": "^2.13.0",
    "cors": "^2.8.5",
    "express": "^4.21.2"
  }
}
GEMINI_API_KEY=your-api-key-here
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3001'
    }
  }
});
{
  "name": "codemate-client",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@monaco-editor/react": "^4.7.0",
    "monaco-editor": "^0.56.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "y-monaco": "^0.1.6",
    "y-websocket": "^3.0.0",
    "yjs": "^13.6.31"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.4.1",
    "vite": "^6.3.5"
  }
}

Your development environment is ready. Next up, you'll build the coding workspace with a problem panel and a VS Code-quality editor.

Build the Coding Workspace

Your dev environment is ready with both servers running. Now you need something worth collaborating on.

Before real-time sync can matter, you need a coding workspace that feels like an actual interview prep tool. A problem description alongside a professional code editor gives you the core experience to build on.

In this step, get ready to:
  • Create the problem data and a panel to display it.
  • Build a code editor powered by Monaco Editor.
  • Wire up a join screen that transitions into the workspace.
Create the problem data and display panel

Every coding interview prep tool starts with a problem to solve. You will define the Two Sum problem as a data object, then build a React component to display it.

  • In your terminal, create a components folder inside client/src by running this command:
mkdir client/src/components
  • Create a new file called problems.js inside client/src/ and add the following code:
export const problem = {
  title: "Two Sum",
  difficulty: "Easy",
  description: `Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.

You may assume that each input would have exactly one solution, and you may not use the same element twice.

You can return the answer in any order.`,
  examples: [
    {
      input: "nums = [2,7,11,15], target = 9",
      output: "[0,1]",
      explanation: "Because nums[0] + nums[1] == 9, we return [0, 1]."
    },
    {
      input: "nums = [3,2,4], target = 6",
      output: "[1,2]",
      explanation: "Because nums[1] + nums[2] == 6, we return [1, 2]."
    }
  ],
  starterCode: `function twoSum(nums, target) {\n  // Your solution here\n\n}\n`
};

What does this code do?

  • Exports a single problem object that holds everything the workspace needs to display a coding challenge.
  • The examples array provides concrete test cases so the learner knows what correct output looks like.
  • The starterCode string is what will pre-fill the code editor when a user joins a room.
  • Create a new file called ProblemPanel.jsx inside client/src/components/ and add the following code:
function ProblemPanel({ problem }) {
  return (
    <div className="problem-panel">
      <div className="problem-header">
        <h3>{problem.title}</h3>
        <span className="difficulty-badge">{problem.difficulty}</span>
      </div>
      <div className="problem-description">
        <p>{problem.description}</p>
      </div>
      <div className="problem-examples">
        <h4>Examples:</h4>
        {problem.examples.map((example, i) => (
          <div key={i} className="example">
            <p><strong>Input:</strong> {example.input}</p>
            <p><strong>Output:</strong> {example.output}</p>
            {example.explanation && (
              <p><strong>Explanation:</strong> {example.explanation}</p>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

export default ProblemPanel;

What does this component do?

ProblemPanel receives the problem object as a prop and renders it as a readable interview problem. It maps over the examples array to display each test case with its input, output, and explanation.

  • Save both files.
  • Confirm your new files exist by running:
ls client/src/components

You should see ProblemPanel.jsx listed in the output.

Don't see ProblemPanel.jsx?

  • Make sure you created the components folder inside client/src/, not in the root or server folder.
  • Check that the filename is exactly ProblemPanel.jsx with a capital P and capital P.

Help me debug my file structure.

Build the code editor and wire up the app

Now you need a professional code editor where users actually write their solution. Monaco Editor is the same editor engine that powers VS Code, and the @monaco-editor/react package makes it easy to embed in a React app.

  • Create a new file called CodeEditor.jsx inside client/src/components/ and start with the imports and component logic:
import { useEffect, useRef, useState } from 'react';
import Editor from '@monaco-editor/react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { MonacoBinding } from 'y-monaco';
import { problem } from '../problems.js';

function CodeEditor({ room, onCodeChange }) {
  const editorRef = useRef(null);
  const providerRef = useRef(null);
  const bindingRef = useRef(null);
  const docRef = useRef(null);
  const [status, setStatus] = useState('connecting');

  useEffect(() => {
    return () => {
      if (bindingRef.current) bindingRef.current.destroy();
      if (providerRef.current) providerRef.current.destroy();
      if (docRef.current) docRef.current.destroy();
    };
  }, []);

  const handleEditorMount = (editor) => {
    editorRef.current = editor;
    // Yjs not yet wired up
  };

What does this code do?

  • The imports pull in Yjs and WebSocket libraries that you will wire up in the next step. For now they sit unused.
  • The useRef hooks store references to the editor, document, and provider so they can be cleaned up when the component unmounts.
  • The useEffect cleanup function ensures WebSocket connections and bindings are destroyed when the user leaves the room.
  • The handleEditorMount callback fires once Monaco finishes loading. Right now it just saves the editor reference.
  • Still in the same file, add the return statement and export directly below the code you just pasted:
  return (
    <div className="code-editor">
      <div className="editor-toolbar">
        <span>JavaScript</span>
        <span className={`status-indicator ${status}`}>
          {status === 'connected' ? 'Connected' : 'Connecting...'}
        </span>
      </div>
      <Editor
        height="400px"
        defaultLanguage="javascript"
        defaultValue={problem.starterCode}
        theme="vs-dark"
        onMount={handleEditorMount}
        options={{
          minimap: { enabled: false },
          fontSize: 14,
          lineNumbers: 'on',
          scrollBeyondLastLine: false,
          automaticLayout: true,
        }}
      />
    </div>
  );
}

export default CodeEditor;

What does the Editor component do?

  • The Editor component from @monaco-editor/react renders a full VS Code-quality editor in the browser.
  • Setting defaultValue to problem.starterCode pre-fills the editor with the function skeleton from your problem data.
  • The toolbar above the editor shows the language and a connection status indicator for later use.
  • Save CodeEditor.jsx.

Now you need to wire everything together in App.jsx. The Vite scaffold created a default App component. You will replace it with a join screen that transitions into the workspace.

  • Open client/src/App.jsx and replace its entire contents with the imports, state, and join handler:
import { useState } from 'react';
import CodeEditor from './components/CodeEditor.jsx';
import ProblemPanel from './components/ProblemPanel.jsx';
import { problem } from './problems.js';

function App() {
  const [room, setRoom] = useState('');
  const [joined, setJoined] = useState(false);
  const [code, setCode] = useState(problem.starterCode);

  const handleJoin = (e) => {
    e.preventDefault();
    if (room.trim()) {
      setJoined(true);
    }
  };
  • Directly below that code in the same file, add the join screen and workspace layout:
  if (!joined) {
    return (
      <div className="join-screen">
        <h1>CodeMate</h1>
        <p>Real-time collaborative coding for interview prep</p>
        <form onSubmit={handleJoin}>
          <input
            type="text"
            value={room}
            onChange={(e) => setRoom(e.target.value)}
            placeholder="Enter room code (e.g. study-session-42)"
          />
          <button type="submit">Join Room</button>
        </form>
      </div>
    );
  }

  return (
    <div className="workspace">
      <header className="workspace-header">
        <h2>CodeMate</h2>
        <span className="room-badge">Room: {room}</span>
      </header>
      <div className="workspace-body">
        <ProblemPanel problem={problem} />
        <div className="editor-section">
          <CodeEditor room={room} onCodeChange={setCode} />
        </div>
      </div>
    </div>
  );
}

export default App;

What does App.jsx do?

The joined state controls which screen the user sees. Before joining, they see a simple form with a room code input. After submitting, the app renders the split-panel workspace.

The code state tracks the current editor content via the onCodeChange prop. This will feed into the AI hint system later.

  • Save App.jsx.
  • Now update client/src/main.jsx to import the correct stylesheet. Find this line:
import './index.css'
  • Replace it with:
import './App.css'
  • Save main.jsx and refresh http://localhost:5173 in your browser.

You should see the CodeMate join screen with a title, subtitle, and a text input for the room code. It will look unstyled for now. That is expected.

Seeing a blank page or error?

  • Check your browser's developer console for import errors. A typo in a file path (like ./component/CodeEditor.jsx instead of ./components/CodeEditor.jsx) will crash the app.
  • Make sure ProblemPanel.jsx and CodeEditor.jsx are both inside the src/components/ folder, not src/ directly.
  • Confirm problems.js is at client/src/problems.js, not inside components/.

Help me fix my import errors.

Style the workspace and observe the isolation

The workspace renders but looks messy without proper styling. You will build a dark-themed CSS file that creates the split-panel layout.

  • Open client/src/App.css and replace its entire contents. Start with the global reset and join screen styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #1a1a2e;
  color: #e0e0e0;
  min-height: 100vh;
}

.join-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  gap: 16px;
}

.join-screen h1 {
  font-size: 3rem;
  color: #7c3aed;
}

.join-screen p {
  color: #a0a0a0;
  margin-bottom: 16px;
}
  • Below that, add the join screen form element styles:
.join-screen form {
  display: flex;
  gap: 8px;
}

.join-screen input {
  padding: 12px 16px;
  border-radius: 8px;
  border: 1px solid #333;
  background: #16213e;
  color: #e0e0e0;
  font-size: 1rem;
  width: 300px;
}

.join-screen button {
  padding: 12px 24px;
  border-radius: 8px;
  border: none;
  background: #7c3aed;
  color: white;
  font-size: 1rem;
  cursor: pointer;
}

.join-screen button:hover {
  background: #6d28d9;
}
  • Save App.css and refresh your browser.

You should see a dark-themed join screen with a purple "CodeMate" heading, a styled input field, and a purple "Join Room" button.

  • Continue in App.css. Add the workspace layout and header styles below your existing code:
.workspace {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.workspace-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 24px;
  background: #16213e;
  border-bottom: 1px solid #333;
}

.workspace-header h2 {
  color: #7c3aed;
  font-size: 1.2rem;
}

.room-badge {
  background: #2d2d44;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 0.85rem;
  color: #a0a0a0;
}

.workspace-body {
  display: flex;
  flex: 1;
  overflow: hidden;
}
  • Add the problem panel styles next:
.problem-panel {
  width: 35%;
  padding: 24px;
  overflow-y: auto;
  border-right: 1px solid #333;
  background: #16213e;
}

.problem-header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;
}

.problem-header h3 {
  font-size: 1.3rem;
}

.difficulty-badge {
  background: #065f46;
  color: #6ee7b7;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 0.75rem;
}

.problem-description {
  margin-bottom: 20px;
  line-height: 1.6;
  white-space: pre-wrap;
}
  • Save App.css, enter any room name in the join screen, and click Join Room.

You should see the Two Sum problem on the left side with its title, green difficulty badge, and examples. The right side shows the Monaco editor loading.

  • Add the remaining styles for examples and the editor section:
.problem-examples h4 {
  margin-bottom: 12px;
  color: #a0a0a0;
}

.example {
  background: #1a1a2e;
  padding: 12px;
  border-radius: 8px;
  margin-bottom: 12px;
  font-size: 0.9rem;
}

.example p {
  margin-bottom: 4px;
}

.editor-section {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.code-editor {
  flex: 1;
}
  • Finally, add the editor toolbar and status indicator styles:
.editor-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 16px;
  background: #1e1e1e;
  border-bottom: 1px solid #333;
  font-size: 0.85rem;
}

.status-indicator {
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 0.75rem;
}

.status-indicator.connected {
  background: #065f46;
  color: #6ee7b7;
}

.status-indicator.connecting {
  background: #78350f;
  color: #fbbf24;
}

What do these styles achieve?

  • The .workspace-body uses flexbox to split the screen: the problem panel takes 35% width, and the editor fills the rest.
  • The .status-indicator has two states (green for connected, amber for connecting) that will reflect the WebSocket connection status in the next step.
  • The dark color scheme (#1a1a2e background, #7c3aed accent) matches the vs-dark theme on the Monaco editor for a cohesive look.
  • Save App.css and also update the title in client/index.html. Find:
<title>Vite + React</title>
  • Replace it with:
<title>CodeMate - Collaborative Coding</title>
  • Save index.html and refresh your browser.

Before you test, what do you think will happen if you open two browser tabs, join the same room name, and type in one tab? Will the other tab see your edits?

  • Open http://localhost:5173 in your browser.
  • Type study-room in the room code input and click Join Room.
  • You should see the Two Sum problem on the left and a working Monaco editor on the right where you can type JavaScript.
  • Open a second browser tab at http://localhost:5173.
  • Enter the same room name study-room and click Join Room.
  • Type something in the first tab's editor and check the second tab.

Nothing you type in one tab appears in the other. Each tab has its own completely independent editor state. Two people in the same "room" cannot see each other's work. That isolation is the gap you will close in the next step.

Editor not loading or showing a spinner?

  • Monaco Editor loads from a CDN on first render. If you are on a slow connection, it may take a few seconds. Wait for it to finish.
  • If the editor stays blank, check the browser console for network errors. Corporate firewalls can block CDN requests.
  • Make sure the @monaco-editor/react package was installed in the client folder, not the root.

Help me get the Monaco editor to load.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files and both tabs show independent editors.

ⓧ I'd like to double check the full code

Your project now has several files. Scroll up and confirm you have each one in order:

  • client/src/problems.js with the Two Sum problem object.
  • client/src/components/ProblemPanel.jsx rendering the problem display.
  • client/src/components/CodeEditor.jsx with Monaco Editor and the placeholder Yjs refs.
  • client/src/App.jsx with the join screen and workspace layout.
  • client/src/App.css with all the dark-themed workspace styles.
  • client/src/main.jsx importing ./App.css (not ./index.css).
  • client/index.html with the title set to CodeMate - Collaborative Coding.

Your coding workspace is complete with a problem display and a professional editor. Next up, you will wire in real-time collaboration so two people in the same room see each other's edits instantly.

Add Real-Time Collaboration

In the last step, you built a working coding workspace with the Two Sum problem and a Monaco editor. You also saw the gap: opening two tabs and joining the same room gave you two completely independent editors.

Right now, your editor has no way to share keystrokes between users. To fix this, you'll wire in Yjs, a CRDT library that merges edits from multiple users without conflicts. Every character typed in one tab will appear instantly in the other.

In this step, get ready to:
  • Wire Yjs collaboration into the Monaco editor so edits sync across tabs.
  • Add CSS for remote cursor selections so you can see your collaborator's position.
  • Test real-time collaboration by typing simultaneously in two browser tabs.
Wire up Yjs collaboration in the CodeEditor

The collaboration system needs three layers: a shared document (Y.Doc), a WebSocket connection to sync that document between users, and a binding that pipes changes between the shared document and your Monaco editor.

  • In VS Code, open client/src/components/CodeEditor.jsx.
  • Find the handleEditorMount function. It currently looks like this:
  const handleEditorMount = (editor) => {
    editorRef.current = editor;
    // Yjs not yet wired up
  };
  • Replace the entire handleEditorMount function with the following code:
  const handleEditorMount = (editor) => {
    editorRef.current = editor;

    // Create a shared Yjs document
    const doc = new Y.Doc();
    docRef.current = doc;

    // Connect to the Yjs public demo server
    const provider = new WebsocketProvider(
      'wss://demos.yjs.dev/ws',
      `codemate-${room}`,
      doc
    );
    providerRef.current = provider;

    // Update connection status in the toolbar
    provider.on('status', ({ status: s }) => {
      setStatus(s);
    });

    // Get a shared text type from the document
    const yText = doc.getText('monaco');

    // Bind the shared text to the Monaco editor
    const binding = new MonacoBinding(
      yText,
      editor.getModel(),
      new Set([editor]),
      provider.awareness
    );
    bindingRef.current = binding;

    editor.getModel().onDidChangeContent(() => {
      onCodeChange(editor.getValue());
    });
  };

What does this code do?

  • Y.Doc() creates a CRDT document that can merge edits from multiple sources without conflicts.
  • WebsocketProvider connects this document to a shared server. The room key codemate-${room} ensures only users who join the same room see each other's edits.
  • doc.getText('monaco') gets a named shared text object inside the document. All users editing the same room share this text.
  • MonacoBinding is the glue layer. It watches the shared text for remote changes and updates the editor. It also watches the editor for local changes and pushes them to the shared text.

Why CRDTs instead of traditional sync?

Traditional approaches (like Operational Transform used in Google Docs) need a central server to resolve conflicts. CRDTs resolve conflicts locally using math. Each user's edits are guaranteed to converge to the same result regardless of network order or timing.

This means you get real-time sync without writing a custom conflict-resolution server. The public Yjs demo server just relays messages.

  • Save CodeEditor.jsx.
  • Switch back to your browser at http://localhost:5173 and refresh the page.
  • Join any room name. Look at the toolbar in the top-right of the editor. You should see the status indicator change from Connecting... to Connected with a green background.

Still seeing Connecting?

  • Make sure you saved CodeEditor.jsx. Vite hot-reloads on save, but unsaved changes don't take effect.
  • Check your browser console for WebSocket errors. The Yjs demo server at wss://demos.yjs.dev/ws requires an internet connection.
  • Confirm that the entire handleEditorMount function was replaced, not just the body inside it.

Why is my WebSocket connection stuck on Connecting?

Add remote cursor CSS

When a collaborator selects or types in the editor, Yjs broadcasts their cursor position. The y-monaco binding renders these as DOM elements with specific class names that you style.

  • Open client/src/App.css.
  • Scroll to the bottom of the file, after the .status-indicator.connecting block.
  • Add the following CSS at the end of the file:
.yRemoteSelection {
  background-color: rgba(124, 58, 237, 0.3);
}

.yRemoteSelectionHead {
  position: absolute;
  border-left: 2px solid #7c3aed;
  border-top: 2px solid #7c3aed;
  height: 100%;
  box-sizing: border-box;
}

What do these classes do?

  • .yRemoteSelection highlights the text range that a remote user has selected, using a semi-transparent purple.
  • .yRemoteSelectionHead renders the blinking cursor indicator showing where a remote user's caret is positioned.
  • Save App.css.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before testing collaboration in the next substep.

ⓧ I'd like to double check the full code

Both files are over 30 lines in total. Scroll up and confirm:

  • In CodeEditor.jsx: the handleEditorMount function creates a Y.Doc, a WebsocketProvider, a MonacoBinding, and listens to the status event.
  • In App.css: the .yRemoteSelection and .yRemoteSelectionHead classes appear at the bottom of the file.
Test real-time collaboration

Time to see the payoff. You'll open two browser tabs, join the same room, and confirm that keystrokes sync in real-time.

  • In your browser, make sure you have one tab open at http://localhost:5173 with a room already joined.
  • Open a second browser tab at http://localhost:5173.
  • In the second tab, type the exact same room name you used in the first tab and click Join Room.

Before you start typing, think about what you expect to happen. If you type in one tab, do you think the text will appear in the other tab instantly, with a delay, or not at all?

  • In the first tab, click inside the editor and type a comment like // hello from tab 1.
  • Switch to the second tab.

You should see your comment appear in the second tab's editor. The text synced through the Yjs CRDT document over WebSocket.

  • Now type something in the second tab's editor.
  • Switch back to the first tab and confirm the second tab's text appeared there too.
  • Try typing in both tabs at the same time. Both edits merge without overwriting each other.

Why don't the edits overwrite each other?

Yjs uses a CRDT algorithm that assigns a unique position to every character. When two users insert at the same spot simultaneously, the algorithm deterministically orders them so both users end up with the same result. No "last writer wins" conflicts.

Edits not syncing between tabs?

  • Double-check that both tabs joined the exact same room name. Room names are case-sensitive.
  • Verify both tabs show Connected in the editor toolbar. If one shows Connecting..., the WebSocket connection has not been established.
  • Refresh both tabs and rejoin the same room. The Yjs BroadcastChannel also syncs tabs on the same machine, so if one tab connected, both should work.

Help me debug why my Yjs real-time sync isn't working between two browser tabs.

Your coding workspace now syncs in real-time. Two people can write code together and every edit merges without conflicts. Next up, you'll add an AI tutor that gives progressive hints when you and your partner get stuck on the problem.

Add AI Hints with Gemini

Your collaborative editor is working. Two people can type in the same room and see each other's edits in real-time.

But imagine you and your study partner are stuck on the Two Sum problem. Without help, you'd leave the app and Google the answer. An AI tutor that gives progressive hints keeps learners engaged and teaches problem-solving patterns instead of just handing over solutions.

In this step, get ready to:
  • Build an Express API endpoint that calls Gemini for progressive hints.
  • Create a HintPanel component that displays escalating hints.
  • Wire the hint system into the workspace so it reads your current code.
Build the AI hint endpoint

The hint system uses a 4-level escalation pattern. Level 1 gives a vague nudge. Level 4 gives the full solution with code. Each level in between gets progressively more specific.

  • Create a new file called server.js inside your server folder, and add the server setup code:
import express from 'express';
import cors from 'cors';
import { GoogleGenAI } from '@google/genai';

const app = express();
app.use(cors());
app.use(express.json());

// Initialize the Gemini AI client
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || 'your-api-key-here' });

// Each level gives progressively more detail
const hintInstructions = [
  "Give a vague conceptual nudge in 1-2 sentences. Do not mention the solution approach directly.",
  "Give a more specific hint about the approach in 2-3 sentences. Mention the data structure that would help, but do not give code.",
  "Give a strong hint with partial pseudocode. Show the key steps but not the complete implementation.",
  "Explain the solution clearly with working code and a brief explanation of why it works."
];

What does this code do?

  • Imports Express for the HTTP server, cors for cross-origin requests, and GoogleGenAI from the @google/genai SDK.
  • The hintInstructions array defines the four escalation levels that control how much detail Gemini reveals.
  • Add the route handler and server start code below the hintInstructions array:
app.post('/api/hint', async (req, res) => {
  try {
    const { problem, code, hintLevel } = req.body;
    // Clamp level between 1 and 4
    const level = Math.min(Math.max(hintLevel || 1, 1), 4);

    const prompt = `You are a patient coding interview tutor helping a student solve a problem.

Problem: ${problem}

Student's current code:
${code || '// No code written yet'}

Hint instruction: ${hintInstructions[level - 1]}

Respond with only the hint, no extra commentary.`;

    const response = await ai.models.generateContent({
      model: 'gemini-2.5-flash',
      contents: prompt,
    });

    res.json({ hint: response.text, level });
  } catch (error) {
    console.error('Hint generation error:', error.message);
    res.status(500).json({ error: 'Failed to generate hint' });
  }
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`CodeMate server running on http://localhost:${PORT}`);
});

What does the hint endpoint do?

  • Accepts a POST request with the problem description, the student's current code, and the requested hint level.
  • Constructs a prompt that tells Gemini to act as a tutor and follow the escalation instruction for that level.
  • Calls ai.models.generateContent with model gemini-2.5-flash and returns the hint text to the client.

Why progressive hints instead of instant answers?

Giving the full solution immediately teaches nothing about problem-solving. The escalation pattern (nudge, approach, pseudocode, solution) mirrors how a good tutor works. Students learn to reason through problems rather than just copy code.

  • Find this line in your server.js:
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || 'your-api-key-here' });
  • Replace your-api-key-here with your actual Gemini API key from Google AI Studio, so the line looks like this:
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || 'your-actual-gemini-key' });

Keep your key out of version control

Your key is hardcoded here for local development only. In production you would load it exclusively from an environment variable and never commit the key to Git.

  • Save server/server.js.
  • Stop your running dev servers by pressing Ctrl+C in the terminal.
  • Restart the dev servers by running this command from the root codemate folder:
npm run dev

You should see CodeMate server running on http://localhost:3001 in your terminal output alongside the Vite startup message.

Server not starting?

  • If you see a syntax error, double-check the template literal backticks in the prompt string. The prompt uses backticks (`) not regular quotes.
  • If you see Cannot find module, make sure you ran npm install inside the server folder during Step 1.
  • Make sure you are running npm run dev from the root codemate folder, not from inside server or client.

Why is my server not starting?

Build the HintPanel component

The HintPanel lives below the code editor. It tracks which hint level the learner is on and sends the current code to the server so Gemini can tailor its response.

  • Create a new file called HintPanel.jsx inside client/src/components/ and add the component logic:
import { useState } from 'react';

// Labels for each escalation level
const hintLabels = ['Nudge', 'Approach', 'Pseudocode', 'Solution'];

function HintPanel({ problem, code }) {
  const [hints, setHints] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hintLevel, setHintLevel] = useState(1);

  const getHint = async () => {
    if (hintLevel > 4) return;
    setLoading(true);
    try {
      const response = await fetch('/api/hint', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          problem: `${problem.title}: ${problem.description}`,
          code,
          hintLevel,
        }),
      });
      const data = await response.json();
      if (data.hint) {
        setHints((prev) => [...prev, { level: hintLevel, text: data.hint }]);
        setHintLevel((prev) => prev + 1);
      }
    } catch (error) {
      console.error('Failed to get hint:', error);
    }
    setLoading(false);
  };

What does getHint do?

  • Sends the problem title, description, current editor code, and the current hint level to your /api/hint endpoint.
  • When a hint comes back, it appends it to the hints array and increments the level so the next click asks for a more detailed response.
  • After level 4, no more hints are available.
  • Add the component's return statement and export below the getHint function:
  return (
    <div className="hint-panel">
      <div className="hint-header">
        <button onClick={getHint} disabled={loading || hintLevel > 4}>
          {loading ? 'Thinking...' : hintLevel > 4 ? 'All hints used' : `Get Hint (${hintLevel}/4 - ${hintLabels[hintLevel - 1]})`}
        </button>
      </div>
      {hints.length > 0 && (
        <div className="hints-list">
          {hints.map((hint, i) => (
            <div key={i} className="hint-item">
              <span className="hint-label">Hint {hint.level} - {hintLabels[hint.level - 1]}</span>
              <p>{hint.text}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

export default HintPanel;

What does the button display?

The button label changes dynamically. It shows "Thinking..." while waiting for Gemini, the current level and label (like "Get Hint (2/4 - Approach)") when ready, and "All hints used" after level 4.

  • Save client/src/components/HintPanel.jsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your HintPanel.jsx contains both chunks in order:

  • The imports, hintLabels array, state hooks, and getHint function.
  • The return statement with the button and hints list, followed by export default HintPanel.
Wire the HintPanel into the workspace

The component exists but nothing renders it yet. You need to import it in App.jsx and pass it the current editor content so Gemini can see what the students have written.

  • In client/src/App.jsx, find this import line:
import ProblemPanel from './components/ProblemPanel.jsx';
  • Add a new import for HintPanel directly below it, so the imports look like this:
import ProblemPanel from './components/ProblemPanel.jsx';
import HintPanel from './components/HintPanel.jsx';
  • Scroll down to the workspace return statement and find this line inside the editor-section div:
          <CodeEditor room={room} onCodeChange={setCode} />
  • Add the HintPanel component directly below that line, passing it the problem and current code:
          <CodeEditor room={room} onCodeChange={setCode} />
          <HintPanel problem={problem} code={code} />
  • Save client/src/App.jsx.
  • Return to your browser at http://localhost:5173 and join a room.

You should see a purple "Get Hint" button appear below the code editor. It may look slightly cramped without styling, but it is functional.

  • Open client/src/App.css and add these hint panel styles at the bottom of the file, below the .yRemoteSelectionHead rule:
.hint-panel {
  border-top: 1px solid #333;
  background: #16213e;
  max-height: 200px;
  overflow-y: auto;
}

.hint-header {
  padding: 12px 16px;
}

.hint-header button {
  padding: 8px 16px;
  border-radius: 6px;
  border: none;
  background: #7c3aed;
  color: white;
  cursor: pointer;
  font-size: 0.85rem;
}

.hint-header button:hover:not(:disabled) {
  background: #6d28d9;
}

.hint-header button:disabled {
  background: #333;
  color: #666;
  cursor: not-allowed;
}
  • Add the hint item styles directly below the rules you just added:
.hints-list {
  padding: 0 16px 16px;
}

.hint-item {
  background: #1a1a2e;
  padding: 12px;
  border-radius: 8px;
  margin-bottom: 8px;
  border-left: 3px solid #7c3aed;
}

.hint-label {
  font-size: 0.75rem;
  color: #7c3aed;
  font-weight: 600;
  text-transform: uppercase;
}

.hint-item p {
  margin-top: 6px;
  line-height: 1.5;
  font-size: 0.9rem;
}

What do these styles do?

The hint panel sits below the editor with a border separator. Each hint card has a purple left border to visually distinguish hint levels. The button disables and greys out after all four hints are used.

  • Save client/src/App.css.

Before you click the button, what do you think will happen when you request a hint? Will it give you the full answer right away, or something lighter?

  • Refresh your browser, join a room, and click the Get Hint (1/4 - Nudge) button.

After a moment, a hint card appears with a vague nudge. Click the button again and you get a more specific hint about the approach. Each click escalates the detail level until you reach hint 4, which gives the full solution.

Hint button not returning anything?

  • Open your browser DevTools console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). If you see a 500 error, your Gemini API key is likely invalid or still set to the placeholder value.
  • If you see a network error, confirm both servers are running. Your terminal should show both the Vite dev server and the CodeMate server running on http://localhost:3001 message.
  • If the button stays disabled immediately, check that your App.jsx passes code={code} to HintPanel.

Why is my Get Hint button not returning a response from Gemini?

Your AI tutor is live. You and your study partner can now collaborate on the Two Sum problem with progressive help that teaches you to think through the solution. Next up, you will add live cursor presence so you can see exactly where your partner is typing.

Secret mission

Add Live User Presence

Your collaboration works, but everyone's cursor looks the same. You can't tell WHO is typing or WHERE they're typing. In this secret mission, you'll add named, colored cursors so each collaborator gets a visible identity in the editor.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing cloud costs. The Yjs public demo server is free and shared, and Gemini API calls stay within the free tier.

Resources you used:

  • Local codemate project folder (client, server, and root config files).
  • Gemini API key stored in server/.env (free tier, no charges expected).
  • Yjs public demo WebSocket server (wss://demos.yjs.dev/ws) used for real-time sync. This is a shared free server with no cleanup needed.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with the collaborative editor.

  • Your dev servers stay available whenever you run npm run dev from the codemate folder.
  • Your Gemini API key remains in server/.env ready for future use.
  • The Yjs demo server requires no maintenance on your end.

Pause - I'll come back to this later

Shut down the running dev servers to free up your terminal and system resources. Your project files stay intact on disk.

  • Switch to the terminal where npm run dev is running.
  • Press Ctrl+C to stop both the Vite dev server and the Express server.
  • When you're ready to resume, navigate back to the codemate folder and run npm run dev again.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • Stop the dev servers by pressing Ctrl+C in the terminal where they are running.
  • Navigate to your Desktop and delete the project folder by running:

macOS

cd ~/Desktop
rm -rf codemate

Windows

cd ~\Desktop
Remove-Item -Recurse -Force codemate
  • Your Gemini API key in Google AI Studio remains active. If you no longer need it, you can revoke it from the Google AI Studio API keys page.

Nice Work!

Nice Work!

Nice work. You just built a real-time collaborative coding workspace with AI-powered tutoring from scratch.

You've learned how to:

  • Build a real-time collaborative editor using Yjs CRDTs and WebSocket synchronization, where two users type simultaneously and see each other's changes instantly.
  • Integrate the Monaco Editor into a React application with room-based collaboration, so study partners can join by sharing a simple room code.
  • Connect to the Gemini API to provide progressive coding hints that adapt to what the student has written, escalating from gentle nudges to full explanations.
  • Implemented the secret mission: live user presence with named colored cursors using the Yjs Awareness protocol.

Ready to quiz yourself?