A full-stack, real-time group chat application built with a Go WebSocket backend and a React frontend featuring a cyberpunk/terminal aesthetic.
- Project Overview
- Architecture Overview
- Tech Stack
- Project Structure
- Backend β Deep Dive
- Frontend β Deep Dive
- Styling & Design System
- Data Flow & Message Protocol
- Gamification System
- Environment Configuration
- Getting Started
- Key Engineering Decisions & Bug Fixes
- Known Limitations & Future Improvements
NEXUS is a real-time, multi-user group chat application that allows multiple browser clients to communicate with each other simultaneously via WebSockets. When a user opens the app, they choose a callsign (username), then join a shared chat room where all connected users can send and receive messages instantly.
The application has a distinctive cyberpunk terminal aesthetic β neon glow effects, CRT scanlines, an Orbitron typeface, and a gamification layer (XP and levels) built on top of the core messaging features.
Key features:
- Real-time bidirectional communication via WebSockets (no polling)
- Shared broadcast room β all connected users see all messages
- System notifications when users join or disconnect
- Username/callsign selection at login
- Message attribution β own messages vs. others are visually differentiated
- Timestamps on every message
- Auto-scroll to the latest message
- Automatic WebSocket reconnection on disconnect
- XP and level progression system
- Live connection status indicator in the header
- Dockerized backend for easy deployment
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β React Frontend (port 3000) β β
β β β β
β β App.js βββΊ api/index.js βββΊ WebSocket Client β β
β β β β β β
β β Header ws://localhost:8080/ws β β
β β ChatHistory β β β
β β ChatInput β β β
β β Message βΌ β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β WebSocket (RFC 6455)
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Go Backend Server (port 8080) β
β β
β main.go βββΊ serveWs() βββΊ websocket.Upgrade() β
β β β
β βΌ β
β Client{ID, Conn, Pool} β
β β β
β pool.Register βββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Connection Pool (goroutine) β β
β β β β
β β Register βββΊ add client βββΊ broadcast join msg β β
β β Unregister βΊ remove client βΊ broadcast leave β β
β β Broadcast βββΊ fan-out message to all clients β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The architecture is intentionally simple: one Go HTTP server handles WebSocket upgrades and maintains a single in-memory connection pool. There is no database, no authentication, and no message persistence β all state lives in memory for the lifetime of the server process.
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Backend language | Go | 1.21 | WebSocket server |
| WebSocket library | gorilla/websocket |
v1.4.0 | HTTPβWS upgrade, read/write |
| Frontend framework | React | 16.14.0 | UI component tree |
| Build toolchain | Create React App / react-scripts | 5.0.1 | Dev server, bundling |
| Styling | SCSS (via sass package) |
^1.69.5 | Component-scoped styles |
| Fonts | Google Fonts (Orbitron, Share Tech Mono) | β | Cyberpunk typography |
| Containerization | Docker (alpine-based Go image) | golang:1.21-alpine | Backend deployment |
GoLang-Realtime-Chat-App/
β
βββ backend/ # Go WebSocket server
β βββ Dockerfile # Container definition for the backend
β βββ go.mod # Go module definition (module path + dependencies)
β βββ go.sum # Dependency checksums
β βββ main.go # Entry point: HTTP server, route setup, WS handshake
β βββ pkg/
β βββ websocket/
β βββ client.go # Client struct, Message struct, read loop
β βββ pool.go # Connection pool, broadcast fan-out goroutine
β βββ websocket.go # HTTPβWebSocket upgrader with origin checking
β
βββ frontend/ # React single-page application
β βββ package.json # npm dependencies and scripts
β βββ public/
β β βββ index.html # HTML shell / mount point
β β βββ favicon.ico
β β βββ manifest.json # PWA manifest
β βββ src/
β βββ App.js # Root component: state management, login screen
β βββ App.css # Global styles, design tokens, login overlay
β βββ index.js # React DOM render entry
β βββ api/
β β βββ index.js # WebSocket connection, send/receive, auto-reconnect
β βββ components/
β βββ Header/
β β βββ Header.jsx # App bar: logo, status, XP bar, player tag
β β βββ Header.scss
β β βββ index.js # Re-export barrel
β βββ ChatHistory/
β β βββ ChatHistory.jsx # Scrollable message list with auto-scroll
β β βββ ChatHistory.scss
β β βββ index.js
β βββ ChatInput/
β β βββ ChatInput.jsx # Text input; sends on Enter keydown
β β βββ ChatInput.scss
β β βββ index.js
β βββ Message/
β βββ Message.jsx # Single message bubble (own / other / system)
β βββ Message.scss
β βββ index.js
β
βββ Chathistory.scss # Root-level SCSS stubs (legacy / unused)
βββ Chatinput.scss
βββ header.scss
βββ message.scss
βββ README.md # (This file)
Note: The four
.scssfiles in the project root (Chathistory.scss,Chatinput.scss,header.scss,message.scss) are legacy stubs and are not imported by any component. The active stylesheets live insidefrontend/src/components/.
backend/main.go
main.go is the server entry point. It does three things:
-
Bootstraps a single connection
Poolby callingwebsocket.NewPool()and launches it as a goroutine withgo pool.Start(). This goroutine runs forever and is responsible for processing all register/unregister/broadcast events. -
Registers the
/wsHTTP route using the standard librarynet/http. Every HTTP request to/wsis handed toserveWs(). -
Calls
http.ListenAndServe(":8080", nil)to start the HTTP server. The server binds to all interfaces on port8080.
The serveWs function:
- Calls
websocket.Upgrade()to perform the HTTPβWebSocket handshake. - Constructs a
Clientstruct, usingr.RemoteAddr(the TCP remote address, e.g.127.0.0.1:54321) as the client's unique ID. - Sends the client to
pool.Register(a channel), which adds it to the active pool. - Calls
client.Read()β a blocking loop that reads incoming messages from this client's WebSocket connection until it disconnects.
backend/pkg/websocket/websocket.go
This file defines the upgrader variable β a gorilla/websocket.Upgrader configured with:
ReadBufferSize: 1024andWriteBufferSize: 1024bytes β suitable for text chat messages.- A
CheckOriginfunction that implements a simple environment-driven CORS policy:- If the
ALLOWED_ORIGINenvironment variable is set, only requests whoseOriginheader matches that value are permitted. All others are rejected. - If
ALLOWED_ORIGINis not set (development mode), all origins are allowed (return true).
- If the
The exported Upgrade() function wraps upgrader.Upgrade() and returns the established *websocket.Conn or an error.
backend/pkg/websocket/client.go
Client struct:
type Client struct {
ID string // Unique identifier (TCP remote address)
Conn *websocket.Conn // The underlying WebSocket connection
Pool *Pool // Pointer back to the shared connection pool
mu sync.Mutex // Guards concurrent writes to Conn
}The mu mutex is critical. WebSocket connections are not safe for concurrent writes. Because the pool's Start() goroutine fans out messages to all clients simultaneously, a client could receive multiple write calls at the same time. SafeWriteJSON() acquires the mutex before every write to prevent data races.
Message struct:
type Message struct {
Type int `json:"type"`
Body string `json:"body"`
}Every piece of data sent over the wire is a Message. Type is the WebSocket message type (1 = text). Body carries the payload β either a plain-text system notification or a JSON-encoded user message (see Message Protocol).
Read() method:
This is a blocking loop that calls c.Conn.ReadMessage() in a for {}. On each iteration:
- It reads the raw bytes from the WebSocket.
- Wraps them in a
Messagestruct. - Sends the
Messagetoc.Pool.Broadcast(a channel). - The deferred function sends the client to
c.Pool.Unregisterand closes the connection when the loop exits (i.e., when the client disconnects or an error occurs).
backend/pkg/websocket/pool.go
The Pool struct is the heart of the server:
type Pool struct {
Register chan *Client // Clients connecting
Unregister chan *Client // Clients disconnecting
Clients map[*Client]bool // Active client set
Broadcast chan Message // Incoming messages to fan out
}All four fields are created by NewPool(). The channels are unbuffered, meaning senders block until the pool's Start() goroutine is ready to receive.
Start() goroutine β event loop:
Start() runs an infinite select over the three channels:
-
Register: Adds the new client to theClientsmap. Then broadcasts a"New User Joined..."system message to every connected client (including the new one). Logs the current pool size. -
Unregister: Removes the client from theClientsmap. Broadcasts a"User Disconnected..."system message to all remaining clients. Logs the current pool size. -
Broadcast: Fans out the receivedMessageto every client in the pool usingSafeWriteJSON. If writing to a client fails, it logs the error and usescontinue(notreturn) so one bad connection does not crash the entire pool goroutine or starve other clients.
A complete message lifecycle from user keystroke to all recipients' screens:
1. User presses Enter in the browser
β
βΌ
2. App.js send() serialises { body, user } as JSON and calls sendMsg(payload)
β
βΌ
3. api/index.js socket.send(payload) β raw string over WebSocket
β
βΌ
4. Go: client.Read() receives the raw bytes
β
βΌ
5. Go: wraps in Message{Type: 1, Body: rawString} and sends to pool.Broadcast channel
β
βΌ
6. Go: pool.Start() receives from Broadcast, iterates all clients, calls SafeWriteJSON(message)
β
βΌ
7. Every browser receives a MessageEvent
β
βΌ
8. api/index.js socket.onmessage fires, calls the App.js callback
β
βΌ
9. App.js appends { data: msg.data, timestamp } to chatHistory state
β
βΌ
10. ChatHistory re-renders, passes each entry to <Message>
β
βΌ
11. Message.jsx parses the outer JSON (from Go), then the inner JSON (the user payload),
and renders a styled bubble attributed to the correct user
This module is responsible for all WebSocket lifecycle management. It exposes three functions:
connect(cb, onStatusChange)
- Reads
process.env.REACT_APP_WS_URLto determine the WebSocket URL; defaults tows://localhost:8080/ws. - Creates a
new WebSocket(wsUrl)and attaches four event handlers:onopenβ callsonStatusChange('connected').onmessageβ calls thecbcallback with the rawMessageEvent. App.js reads.datafrom it.oncloseβ callsonStatusChange('disconnected')then schedules a reconnect after 3 seconds viasetTimeout(() => connect(cb, onStatusChange), 3000). This means the app continuously attempts to re-establish the connection without user intervention.onerrorβ callsonStatusChange('error').
sendMsg(msg)
Checks that the socket exists and readyState === WebSocket.OPEN before calling socket.send(msg). Returns false if the socket is not ready (prevents silent failures).
disconnect()
Sets socket.onclose = null before calling socket.close() to prevent the auto-reconnect from firing on a deliberate disconnect.
App is a class component that owns all top-level state:
| State field | Type | Purpose |
|---|---|---|
chatHistory |
Array<{data, timestamp}> |
All messages received since connection |
username |
string |
Confirmed callsign (empty = not logged in) |
usernameInput |
string |
Controlled input value on the login screen |
connectionStatus |
string |
'connected' / 'disconnected' / 'error' |
messageCount |
number |
Number of messages sent by this user |
xp |
number |
Accumulated XP points |
Login gate: If username is empty, a full-screen login overlay is rendered (div.login-overlay) with a callsign input and a "JACK IN" submit button. Once submitted, username is set and the main UI renders.
componentDidMount: Calls connect() from the API layer. The onmessage callback pushes { data: msg.data, timestamp: Date.now() } into chatHistory and awards 5 XP. The status callback updates connectionStatus.
send(event): Fires on keydown. Checks for keyCode === 13 (Enter) and a non-empty trimmed value. Serialises { body, user } as JSON and calls sendMsg. Clears the input, increments messageCount, and awards 10 XP.
getLevel() / getXpProgress(): Derive the current level (Math.floor(xp / 100) + 1) and the XP progress within the current level (xp % 100) from the raw xp value.
frontend/src/components/Header/Header.jsx
A stateless functional component that receives { username, level, xpProgress, messageCount, connectionStatus } as props and renders three regions:
- Left:
[NEXUS]logo with pink bracket accents, a pulsing status dot, and a status label (ONLINE/OFFLINE/ERROR/CONNECTING). - Center: A level badge (
LVL N), an animated XP progress bar (filled width driven byxpProgress%), and an XP label (N/100 XP). The bar uses a cyan-to-pink gradient and a smooth CSS transition. - Right: A message count stat block and a player tag showing the user's callsign with a
βicon.
The connectionStatus prop drives conditional CSS classes on the dot and label to switch between green (connected), red (disconnected), and yellow (error) colouring.
frontend/src/components/ChatHistory/ChatHistory.jsx
A class component that renders the scrollable list of all messages. Key behaviours:
- Empty state: If
chatHistoryis empty, renders a centred placeholder with a π‘ icon and "Awaiting transmissions..." text. - Message rendering: Maps over
chatHistory, rendering a<Message>for each entry, keyed by array index. Passesmsg.data,username(ascurrentUser), andmsg.timestamp. - Auto-scroll: Uses a
React.createRef()pointing to an invisible<div ref={this.chatEndRef} />at the bottom of the list.componentDidUpdatecallschatEndRef.current.scrollIntoView({ behavior: 'smooth' })after every render, ensuring the view always follows the latest message. - Custom scrollbar: SCSS sets a 4px cyan-tinted scrollbar via
::-webkit-scrollbarandscrollbar-width: thin.
frontend/src/components/Message/Message.jsx
The most complex component. It handles three message variants by parsing the raw WebSocket data:
Parsing logic (in parseMessage()):
The Go backend wraps all messages in an outer JSON envelope: { "type": 1, "body": "..." }. The body field contains either:
- A plain string like
"New User Joined..."β a system/server notification. - A JSON string like
{"body":"hello","user":"Alice"}β a user-sent message.
parseMessage() attempts to JSON.parse the outer envelope first. Then it tries to JSON.parse the body. If the inner parse succeeds and the result has a body field, it is a user message. If the inner parse fails, body is plain text and it is treated as a system message. The result is stored in this.state.parsed.
Rendering:
- System message (
.system): Centred, amber-coloured bubble with no author attribution. - Own message (
.me): Right-aligned, cyan-tinted bubble. No author label (it's you). - Other's message (
.other): Left-aligned, pink-tinted bubble withβ Usernameattribution.
A timestamp is displayed in HH:MM format if present. Messages animate in with a subtle slideIn keyframe (fade + translate up 8px).
frontend/src/components/ChatInput/ChatInput.jsx
A deliberately simple class component. Renders a styled <input> element with:
onKeyDown={this.props.send}β delegates all key handling toApp.js.autoFocusβ the input is focused immediately when the main chat UI mounts.maxLength={500}β server-side messages are also capped implicitly by the WebSocket buffer.- A
βΊprompt glyph prepended via::beforepseudo-element. - An
"ENTER TO SEND"hint label to the right.
The app uses a consistent cyberpunk/terminal design language defined via CSS custom properties in App.css:
| Token | Value | Usage |
|---|---|---|
--neon-cyan |
#00f5ff |
Primary accent, logos, own messages, borders |
--neon-pink |
#ff00aa |
Secondary accent, other users' messages, logo brackets |
--neon-green |
#00ff88 |
Online status indicator |
--neon-yellow |
#ffee00 |
XP system warnings, system messages |
--bg-deep |
#0a0a12 |
Page background |
--bg-panel |
#0f0f1e |
Header and input bar backgrounds |
--bg-card |
#14142a |
Card surfaces |
--text-primary |
#e0e8ff |
Primary readable text |
--text-dim |
#4a5a7a |
Subdued labels, placeholders |
Typography:
Orbitron(Google Fonts) β used for all headings, logo, and level badges. Bold, geometric, sci-fi.Share Tech Mono(Google Fonts) β used for all body copy and input text. Monospaced terminal feel.
Special effects:
- CRT Scanlines: An
App::beforepseudo-element overlays a repeating horizontal line gradient at z-index 9998, simulating an old CRT monitor. - Neon glow:
text-shadowandbox-shadowwith semi-transparent neon colours on nearly all interactive elements. - Radial background gradients: Three overlapping radial gradients on the
.Appcontainer subtly tint the background. - Logo flicker animation:
@keyframes logo-flickeron the login screen drops opacity at specific keyframes to simulate a flickering neon sign. - Pulsing status dot:
@keyframes pulse-dotfades and enlarges the green dot to draw attention to connectivity status. - Message slide-in:
@keyframes slideIngives each new message a 200ms fade + upward translate.
All messages exchanged over the WebSocket use two layers of JSON:
Layer 1 β Go backend envelope (written by pool.go / client.go via SafeWriteJSON):
{
"type": 1,
"body": "<string payload>"
}Layer 2 β User message payload (written by App.js send(), embedded as a JSON string inside body):
{
"body": "The actual chat message text",
"user": "Alice"
}System messages do not have a Layer 2 β their body is a plain string:
{ "type": 1, "body": "New User Joined..." }
{ "type": 1, "body": "User Disconnected..." }The Message.jsx component must handle both cases, which it does via a nested try/catch parse strategy.
The app includes a lightweight XP and level system that persists for the duration of a browser session (not stored server-side):
| Action | XP Reward |
|---|---|
| Receiving a message | +5 XP |
| Sending a message | +10 XP |
Level calculation:
- Level =
Math.floor(totalXP / 100) + 1 - XP within current level =
totalXP % 100 - The XP bar in the header shows progress from 0 to 100 within the current level.
This is all managed in App.js state (xp, messageCount) and passed down to Header as props. No server interaction is required.
| Variable | Default | Description |
|---|---|---|
ALLOWED_ORIGIN |
(none) | If set, restricts WebSocket connections to requests from this exact origin (e.g. https://yourapp.com). If unset, all origins are permitted (development mode). |
| Variable | Default | Description |
|---|---|---|
REACT_APP_WS_URL |
ws://localhost:8080/ws |
WebSocket server URL. Override for production (e.g. wss://yourserver.com/ws). Must be set at build time as a REACT_APP_ prefixed env var for Create React App. |
Create a .env file in frontend/ for local overrides:
REACT_APP_WS_URL=ws://localhost:8080/ws- Go 1.21+ β Install Go
- Node.js 16+ and npm β Install Node.js
- Docker (optional, for containerised backend) β Install Docker
Step 1: Start the backend
cd backend
go mod download # Fetch gorilla/websocket dependency
go run main.go # Start the WebSocket server on :8080You should see:
Distributed Chat App v0.01
Step 2: Start the frontend
In a separate terminal:
cd frontend
npm install # Install React dependencies
npm start # Start CRA dev server on :3000The browser will open automatically at http://localhost:3000.
Step 3: Test with multiple tabs
Open two or more browser tabs at http://localhost:3000. Each tab acts as an independent user. Messages sent in one tab will appear instantly in all others.
The backend includes a Dockerfile based on golang:1.21-alpine:
cd backend
docker build -t nexus-backend .
docker run -p 8080:8080 -e ALLOWED_ORIGIN=http://localhost:3000 nexus-backendThen start the frontend separately with npm start as described above.
For a production setup, build the React app and serve it from a static host or CDN:
cd frontend
REACT_APP_WS_URL=wss://your-backend-domain.com/ws npm run build
# Deploy the build/ directory to your static hostSeveral important issues were identified and resolved during development:
1. Concurrent WebSocket writes (race condition)
gorilla/websocket connections are not safe for concurrent writes. Since the pool fans out messages to all clients from a single goroutine but multiple events could arrive in quick succession, client.go introduces sync.Mutex (mu) and a SafeWriteJSON wrapper method. All writes go through this method.
2. Pool goroutine survival on bad client
The original Broadcast handler used return on write error, which would kill the pool's Start() goroutine entirely if any single client had a broken connection. This was changed to continue so the pool remains alive and serves all other connected clients.
3. MessageEvent non-enumerable properties
msg.data from a WebSocket MessageEvent is a non-enumerable property. Spreading the event object ({ ...msg }) loses it entirely. The componentDidMount callback in App.js explicitly captures msg.data rather than spreading the event.
4. CSS class mismatch
The SCSS defines .me and .other classes on .Message. An earlier version of Message.jsx applied mine and theirs β classes that don't exist in the stylesheet. The JSX was corrected to use me / other.
5. System messages missing bubble wrapper
System messages were rendered without the .msg-bubble wrapper div, meaning the SCSS rules for .Message.system .msg-bubble had no target. The system message render branch was updated to include the wrapper.
6. Docker binary naming
Without the -o flag, go build names the output binary after the working directory (app). The Dockerfile now uses go build -o /app/realtime-chat-go-react . for an unambiguous binary name.
7. Origin checking in production
The CheckOrigin function defaults to allowing all origins (safe for local development). For production, set ALLOWED_ORIGIN to your frontend's exact origin to prevent cross-origin WebSocket abuse.
Current limitations:
- No message persistence β messages are lost when the server restarts or a client refreshes.
- No private rooms or channels β all clients share a single broadcast pool.
- No authentication β any callsign can be claimed by any user; there is no uniqueness enforcement.
- Client ID is TCP address β if clients share a NAT, they may have colliding IDs (though this only affects server-side logging, not functionality).
- XP is session-only β refreshing the page resets XP and level.
- No message history on join β new users see only messages received after they connect.
Potential improvements:
- Add Redis or a time-series database for message persistence and history replay on join.
- Implement named chat rooms with per-room pools.
- Add JWT-based authentication with unique username reservation.
- Introduce a
docker-compose.ymlto run backend and frontend together. - Replace class components with React hooks (
useState,useEffect,useRef). - Add end-to-end tests (e.g. Playwright for the UI,
net/http/httptest+gorilla/websocketfor the backend). - Add typing indicators via a lightweight broadcast of ephemeral events.
- Store XP/level in
localStorageso it survives page refreshes.