Build a Multiplayer Quiz Game
Estimated time: 5–6 hours | Difficulty: Advanced
What You Will Build
- A real-time multiplayer quiz game powered by WebSockets
- A Spring Boot backend that manages game state, players, and scores
- A browser-based frontend where players join from their phones
- A live scoreboard that updates instantly as answers come in
- A library of programming trivia questions that double as a study tool
- A polished experience with countdown timers, animations, and a winner podium
1. What Are WebSockets?
Every web application you have built so far uses HTTP — the protocol that powers the web. HTTP follows a simple pattern called request-response. The browser sends a request ("give me this page" or "save this data"), the server processes it, sends back a response, and the connection closes. Every interaction is a separate round trip. The browser asks, the server answers, and then they stop talking until the browser asks again.
This works perfectly for most things. Loading pages, submitting forms, fetching search results — all of these are natural request-response interactions. But think about what happens when you need something different. Think about a chat application. If Alice sends a message, Bob's browser needs to see it immediately — not the next time Bob's browser happens to ask the server "any new messages?" Think about live sports scores. You want the score to update the instant a goal is scored, not when you manually refresh the page.
This is where WebSockets come in.
HTTP vs WebSockets
HTTP is like sending text messages. You send a message, you wait for a reply, and the conversation pauses until someone sends the next message. Each exchange is independent. WebSockets are like a phone call. Once the connection is established, both sides can talk whenever they want, as much as they want, without having to "dial" again each time. The line stays open. Data flows freely in both directions.
Think about it as the difference between texting and a phone call. With texting (HTTP), you compose a message, send it, and wait. The other person reads it, types a reply, and sends it back. There is a delay between each exchange. With a phone call (WebSockets), you pick up the phone once, and then you can speak freely in real time. There is no lag between "messages" because the connection is always open. Both people can talk at the same time. Information flows instantly.
WebSockets are perfect for situations where you need real-time, bidirectional communication:
- Chat applications — messages need to appear instantly for all participants
- Live scores and dashboards — data needs to push to the browser the moment it changes
- Multiplayer games — player actions need to be shared with everyone in real time
- Collaborative editing — like Google Docs, where multiple people type in the same document
- Stock tickers — prices change rapidly and the display needs to keep up
For your multiplayer quiz game, WebSockets are essential. When the host starts a question, every player's screen needs to show that question at the same time. When a player submits an answer, the host's scoreboard needs to update immediately. When the timer runs out, everyone needs to know. HTTP would require every player's browser to constantly ask "anything new? anything new? anything new?" — hundreds of requests per second, most of them returning nothing. WebSockets keep the line open so the server can push updates the instant they happen.
To add WebSocket support to a Spring Boot project, you need one dependency in your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
This single dependency brings in everything you need: the WebSocket protocol support, the STOMP messaging layer, and the message broker that routes messages between clients and the server. You do not need to install anything else. Spring Boot handles the wiring.
2. WebSocket Setup in Spring Boot
Raw WebSockets give you a pipe for sending messages back and forth, but they do not provide any structure. It is like having a phone line but no language — you can make sounds, but there is no grammar or vocabulary to organize them. In practice, you want a messaging protocol on top of WebSockets that defines how messages are formatted, how they are routed, and how clients subscribe to different topics.
Spring Boot uses STOMP (Simple Text Oriented Messaging Protocol) for this. STOMP adds structure to your WebSocket messages. Instead of sending raw text, you send messages to destinations (like URLs), and clients subscribe to destinations they care about. Think of it like a radio station: the server broadcasts on specific channels, and clients tune in to the channels they want to hear.
STOMP in Plain English
STOMP gives your WebSocket messages an address system. The server can send a message to /topic/game/ABC123/question, and only clients subscribed to that destination will receive it. This means players in Game A do not see messages from Game B. It is organized, efficient, and exactly what you need for a multiplayer game where multiple games might be running at the same time.
To configure WebSockets in Spring Boot, you create a configuration class. This class tells Spring where to set up the WebSocket endpoint and how to route messages:
package com.example.quizgame.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// Messages sent to /topic will be broadcast to subscribers
config.enableSimpleBroker("/topic");
// Messages sent from clients go to /app prefix
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// This is the URL clients connect to
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
Let us break down what each part does:
@EnableWebSocketMessageBroker— This annotation activates WebSocket message handling with STOMP support. Without it, Spring ignores your WebSocket configuration entirely.enableSimpleBroker("/topic")— This creates an in-memory message broker. Any message sent to a destination starting with/topicwill be broadcast to all subscribed clients. For example, a message to/topic/game/ABC123/scoreswill reach every player subscribed to that destination.setApplicationDestinationPrefixes("/app")— When a client sends a message to the server (like submitting an answer), the destination starts with/app. Spring routes these to your@MessageMappingcontroller methods.addEndpoint("/ws")— This is the URL that clients use to establish the WebSocket connection. When a player opens the game in their browser, JavaScript connects to/ws.withSockJS()— SockJS is a fallback library. If a player's browser or network does not support WebSockets (rare, but possible), SockJS automatically falls back to other techniques like long-polling to simulate the same behavior.
Before building the full game, let us verify the WebSocket connection works with a simple echo test. Create a controller that receives a message and sends it back:
package com.example.quizgame.controller;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
@Controller
public class EchoController {
@MessageMapping("/echo")
@SendTo("/topic/echoes")
public String echo(String message) {
return "Server received: " + message;
}
}
When a client sends a message to /app/echo, Spring strips the /app prefix, matches it to the @MessageMapping("/echo") method, processes it, and broadcasts the return value to everyone subscribed to /topic/echoes. The message flows in, gets processed, and flows back out — all over the same persistent WebSocket connection. No HTTP round trips. No delays. Start your application and verify you see no errors in the console. You will test this with a real frontend in Part 5.
3. Game Architecture
Before you write another line of code, you need a plan. A multiplayer quiz game has many moving parts: players joining, questions being asked, answers being submitted, scores being calculated, timers counting down. Without a clear architecture, the code will become a tangled mess. Let us design the data model and the game flow first.
Data Model
You need three core classes to represent the game state:
Game
Represents a single quiz session. Contains a unique join code (like "ABC123") that players use to enter the game, a reference to the host (the person who created the game), a list of questions, a list of players, the current game state (lobby, playing, or finished), and the index of the current question. Each game is independent — multiple games can run at the same time.
Player
Represents someone playing the game. Contains the player's name (chosen when they join), their session ID (so the server knows which WebSocket connection belongs to which player), their score, and a flag for whether they have answered the current question. The session ID is critical — it is how the server routes messages to the right person.
Question
Represents a single quiz question. Contains the question text, an array of four answer options, the index of the correct answer, and optionally a category tag (HTML, CSS, JavaScript, Java, SQL, etc.) and a difficulty level. Keeping categories lets you filter or mix questions for different audiences.
Game Flow
The game follows a clear sequence of steps. Understanding this flow is critical before you start coding:
- Create — The host clicks "Create Game." The server generates a unique join code (like "XK7M") and creates a new Game object in the LOBBY state.
- Share Join Code — The host's screen displays the join code in large, readable text. Players pull out their phones, open the game URL, and enter the code.
- Lobby — As each player joins, their name appears on the host's screen in real time. The host sees the player list grow. Players see a "waiting for host" screen. This is the gathering phase.
- Start — When enough players have joined, the host clicks "Start Game." The server switches the game state to PLAYING and sends the first question to all players simultaneously.
- Questions — Each question has a countdown timer (15 or 20 seconds). Players see the question and four options on their phones. They tap their answer. Faster correct answers earn more points. When the timer expires or all players have answered, the server broadcasts the correct answer and updated scores.
- Scores — Between questions, a scoreboard appears showing rankings. Players see where they stand. Then the next question begins.
- Winner — After the last question, the server switches the game state to FINISHED and broadcasts the final scores. The top three players get a podium display.
State Machine
The game's behavior at any moment depends on its state. This is a common pattern in software called a state machine. Your game has three states:
- LOBBY — The game has been created but has not started. Players can join. The host sees the player list. No questions are active. The only valid action is "Start Game" (by the host) or "Join" (by a player).
- PLAYING — The game is active. Questions are being displayed. Players are submitting answers. The timer is running. No new players can join. The game cycles through questions until they are all done.
- FINISHED — All questions have been answered. Final scores are displayed. The winner is announced. The only valid action is "Play Again" (which creates a new game and returns to LOBBY).
Every message the server receives should check the game state before processing. If someone tries to submit an answer while the game is in LOBBY, the server ignores it. If someone tries to join while the game is PLAYING, the server rejects it. The state machine keeps the game logic clean and predictable. Without it, you would have dozens of if statements scattered everywhere trying to figure out "is this action allowed right now?" With it, you check the state once and know exactly what to do.
4. Building the Backend
Now let us build the server-side logic. The backend is responsible for everything: creating games, tracking players, managing the question timer, calculating scores, and broadcasting updates. You will not use a database for this project. The game state lives entirely in memory, which is perfect for a real-time game that does not need to persist data after it ends.
In-Memory Game Storage
Create a service class that stores all active games in a HashMap. The join code is the key, and the Game object is the value:
package com.example.quizgame.service;
import com.example.quizgame.model.Game;
import com.example.quizgame.model.GameState;
import com.example.quizgame.model.Player;
import com.example.quizgame.model.Question;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class GameService {
private final Map<String, Game> activeGames = new ConcurrentHashMap<>();
private final Random random = new Random();
public Game createGame(String hostName) {
String joinCode = generateJoinCode();
Game game = new Game();
game.setJoinCode(joinCode);
game.setHostName(hostName);
game.setState(GameState.LOBBY);
game.setPlayers(new ArrayList<>());
game.setQuestions(loadQuestions());
game.setCurrentQuestionIndex(-1);
activeGames.put(joinCode, game);
return game;
}
public Game getGame(String joinCode) {
return activeGames.get(joinCode.toUpperCase());
}
public Player addPlayer(String joinCode, String playerName, String sessionId) {
Game game = getGame(joinCode);
if (game == null || game.getState() != GameState.LOBBY) {
return null;
}
Player player = new Player();
player.setName(playerName);
player.setSessionId(sessionId);
player.setScore(0);
player.setAnswered(false);
game.getPlayers().add(player);
return player;
}
public void removeGame(String joinCode) {
activeGames.remove(joinCode.toUpperCase());
}
private String generateJoinCode() {
String chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
StringBuilder code = new StringBuilder();
for (int i = 0; i < 4; i++) {
code.append(chars.charAt(random.nextInt(chars.length())));
}
String joinCode = code.toString();
// Make sure the code is unique
if (activeGames.containsKey(joinCode)) {
return generateJoinCode();
}
return joinCode;
}
private List<Question> loadQuestions() {
// Questions are loaded in Part 6
return new ArrayList<>();
}
}
Notice several important design decisions in this code:
ConcurrentHashMapinstead of a regularHashMap. Because multiple WebSocket connections can access the game state simultaneously (players joining, submitting answers, the timer ticking), you need a thread-safe map.ConcurrentHashMaphandles concurrent access without requiring you to write your own synchronization code.- The join code excludes confusing characters. Look at the
charsstring: there is noO(looks like0), noI(looks like1), noL(looks like1). When players are squinting at a projector screen trying to type a code into their phones, the last thing you want is ambiguity. This tiny detail is the difference between a smooth experience and frustrated players asking "is that an O or a zero?" - State checking on join. The
addPlayermethod checks that the game exists and is in the LOBBY state before allowing a player to join. This enforces the state machine from Part 3.
REST Endpoints
You need two REST endpoints: one to create a game and one to join a game. These use regular HTTP because they are one-time actions (not real-time streams):
package com.example.quizgame.controller;
import com.example.quizgame.model.Game;
import com.example.quizgame.model.Player;
import com.example.quizgame.service.GameService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/game")
@CrossOrigin(origins = "*")
public class GameRestController {
private final GameService gameService;
public GameRestController(GameService gameService) {
this.gameService = gameService;
}
@PostMapping("/create")
public ResponseEntity<?> createGame(@RequestBody Map<String, String> request) {
String hostName = request.get("hostName");
if (hostName == null || hostName.isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Host name is required"));
}
Game game = gameService.createGame(hostName);
return ResponseEntity.ok(Map.of(
"joinCode", game.getJoinCode(),
"hostName", game.getHostName()
));
}
@PostMapping("/join")
public ResponseEntity<?> joinGame(@RequestBody Map<String, String> request) {
String joinCode = request.get("joinCode");
String playerName = request.get("playerName");
if (joinCode == null || playerName == null || playerName.isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Join code and player name are required"));
}
Game game = gameService.getGame(joinCode);
if (game == null) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Game not found. Check the join code."));
}
return ResponseEntity.ok(Map.of(
"joinCode", game.getJoinCode(),
"playerName", playerName,
"status", "connected"
));
}
}
WebSocket Message Handling
The real-time game logic happens through WebSocket messages. Your game controller handles four types of messages that flow through the system:
- QUESTION_START — Server broadcasts the current question to all players. Includes the question text, four options, question number, and total count. Does not include the correct answer (that would let players cheat by reading the WebSocket messages).
- SUBMIT_ANSWER — A player sends their chosen answer to the server. The server validates it, calculates points based on speed, and marks the player as having answered.
- SCORES_UPDATE — Server broadcasts the current scoreboard to all players after each question. Includes player names, scores, and rankings sorted from highest to lowest.
- GAME_OVER — Server broadcasts the final results when all questions have been answered. Includes the winner's name and the complete final scoreboard.
package com.example.quizgame.controller;
import com.example.quizgame.model.*;
import com.example.quizgame.service.GameService;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import java.util.*;
import java.util.stream.Collectors;
@Controller
public class GameWebSocketController {
private final GameService gameService;
private final SimpMessagingTemplate messagingTemplate;
public GameWebSocketController(GameService gameService,
SimpMessagingTemplate messagingTemplate) {
this.gameService = gameService;
this.messagingTemplate = messagingTemplate;
}
@MessageMapping("/game/{joinCode}/start")
public void startGame(@DestinationVariable String joinCode) {
Game game = gameService.getGame(joinCode);
if (game == null || game.getState() != GameState.LOBBY) return;
game.setState(GameState.PLAYING);
sendNextQuestion(game);
}
@MessageMapping("/game/{joinCode}/answer")
public void submitAnswer(@DestinationVariable String joinCode,
AnswerSubmission submission) {
Game game = gameService.getGame(joinCode);
if (game == null || game.getState() != GameState.PLAYING) return;
Player player = game.getPlayerBySessionId(submission.getSessionId());
if (player == null || player.hasAnswered()) return;
player.setAnswered(true);
Question current = game.getCurrentQuestion();
if (submission.getAnswerIndex() == current.getCorrectIndex()) {
// Faster answers earn more points (max 1000, min 500)
long elapsed = submission.getTimeElapsed();
int points = Math.max(500, 1000 - (int)(elapsed * 50));
player.setScore(player.getScore() + points);
}
// Check if all players have answered
boolean allAnswered = game.getPlayers().stream()
.allMatch(Player::hasAnswered);
if (allAnswered) {
sendScoresUpdate(game);
}
}
private void sendNextQuestion(Game game) {
game.advanceQuestion();
if (game.getCurrentQuestionIndex() >= game.getQuestions().size()) {
endGame(game);
return;
}
Question q = game.getCurrentQuestion();
game.getPlayers().forEach(p -> p.setAnswered(false));
Map<String, Object> questionData = new LinkedHashMap<>();
questionData.put("type", "QUESTION_START");
questionData.put("questionNumber", game.getCurrentQuestionIndex() + 1);
questionData.put("totalQuestions", game.getQuestions().size());
questionData.put("text", q.getText());
questionData.put("options", q.getOptions());
questionData.put("category", q.getCategory());
questionData.put("timeLimit", 15);
String destination = "/topic/game/" + game.getJoinCode();
messagingTemplate.convertAndSend(destination, questionData);
}
private void sendScoresUpdate(Game game) {
List<Map<String, Object>> scores = game.getPlayers().stream()
.sorted(Comparator.comparingInt(Player::getScore).reversed())
.map(p -> {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", p.getName());
entry.put("score", p.getScore());
return entry;
})
.collect(Collectors.toList());
Map<String, Object> data = new LinkedHashMap<>();
data.put("type", "SCORES_UPDATE");
data.put("scores", scores);
String destination = "/topic/game/" + game.getJoinCode();
messagingTemplate.convertAndSend(destination, data);
}
private void endGame(Game game) {
game.setState(GameState.FINISHED);
List<Map<String, Object>> finalScores = game.getPlayers().stream()
.sorted(Comparator.comparingInt(Player::getScore).reversed())
.map(p -> {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", p.getName());
entry.put("score", p.getScore());
return entry;
})
.collect(Collectors.toList());
Map<String, Object> data = new LinkedHashMap<>();
data.put("type", "GAME_OVER");
data.put("winner", finalScores.isEmpty() ? "Nobody" :
finalScores.get(0).get("name"));
data.put("scores", finalScores);
String destination = "/topic/game/" + game.getJoinCode();
messagingTemplate.convertAndSend(destination, data);
}
}
The key class here is SimpMessagingTemplate. Spring injects this automatically, and it is your tool for sending messages to WebSocket subscribers. The convertAndSend method takes a destination (like /topic/game/XK7M) and an object, converts the object to JSON, and broadcasts it to every client subscribed to that destination. This is how the server pushes real-time updates without the client asking for them.
Timer Logic
Each question needs a countdown timer. When 15 seconds are up, the server should automatically end the question and show scores, even if some players have not answered. Java's ScheduledExecutorService is the right tool for this:
package com.example.quizgame.service;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.*;
@Component
public class QuestionTimerService {
private final ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(4);
private final Map<String, ScheduledFuture<?>> activeTimers =
new ConcurrentHashMap<>();
/**
* Start a countdown for a specific game.
* When time runs out, the callback fires.
*/
public void startTimer(String joinCode, int seconds, Runnable onTimeUp) {
// Cancel any existing timer for this game
cancelTimer(joinCode);
ScheduledFuture<?> future = scheduler.schedule(
onTimeUp,
seconds,
TimeUnit.SECONDS
);
activeTimers.put(joinCode, future);
}
/**
* Cancel the timer for a specific game.
* Called when all players answer before time runs out.
*/
public void cancelTimer(String joinCode) {
ScheduledFuture<?> existing = activeTimers.remove(joinCode);
if (existing != null) {
existing.cancel(false);
}
}
}
The ScheduledExecutorService lets you schedule a task to run after a delay. When you call startTimer("XK7M", 15, callback), it waits 15 seconds and then runs the callback. The callback triggers score calculation and advances to the next question. If all players answer before the timer runs out, you cancel the timer and proceed immediately — no one wants to stare at a blank screen for 10 seconds when everyone has already answered.
The activeTimers map tracks one timer per game. Before starting a new timer, the code cancels any existing one for that game. This prevents timer leaks — leftover timers from previous questions firing at unexpected times.
5. Building the Frontend
The frontend is what players actually see and interact with. Your game needs three distinct views, each serving a different purpose:
Three Views
- Host View — Displayed on a large screen or projector. Shows the join code prominently, the player lobby as people join, the current question during gameplay, and the live scoreboard between questions. The host controls the game flow (starting, advancing).
- Join View — The landing page players see on their phones. A simple form with two fields: join code and player name. Clean, fast, mobile-first. No distractions.
- Player View — What players see on their phones during the game. Shows the question text, four large tappable answer buttons, a countdown timer, and score updates. Must work perfectly on small screens because most players will be on phones.
To connect to WebSockets from JavaScript, you use the STOMP.js library along with SockJS. These are the client-side counterparts to the server-side configuration you wrote in Part 2. Here is how to set up the WebSocket connection:
<!-- Include STOMP and SockJS libraries -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script>
// Connect to the WebSocket endpoint
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);
// Disable debug logging in production
stompClient.debug = null;
stompClient.connect({}, function(frame) {
console.log('Connected to game server');
// Subscribe to game updates for this specific game
const joinCode = 'XK7M'; // From the join process
stompClient.subscribe('/topic/game/' + joinCode, function(message) {
const data = JSON.parse(message.body);
switch (data.type) {
case 'QUESTION_START':
showQuestion(data);
break;
case 'SCORES_UPDATE':
showScores(data.scores);
break;
case 'GAME_OVER':
showWinner(data);
break;
case 'PLAYER_JOINED':
updatePlayerList(data);
break;
}
});
});
// Send an answer to the server
function submitAnswer(answerIndex) {
stompClient.send('/app/game/' + joinCode + '/answer', {}, JSON.stringify({
sessionId: mySessionId,
answerIndex: answerIndex,
timeElapsed: getElapsedSeconds()
}));
}
function showQuestion(data) {
document.getElementById('question-text').textContent = data.text;
document.getElementById('question-counter').textContent =
'Question ' + data.questionNumber + ' of ' + data.totalQuestions;
document.getElementById('category-tag').textContent = data.category;
const optionsContainer = document.getElementById('answer-options');
optionsContainer.innerHTML = '';
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12'];
data.options.forEach(function(option, index) {
const button = document.createElement('button');
button.className = 'answer-btn';
button.style.backgroundColor = colors[index];
button.textContent = option;
button.addEventListener('click', function() {
submitAnswer(index);
disableAllButtons();
});
optionsContainer.appendChild(button);
});
startCountdown(data.timeLimit);
}
function startCountdown(seconds) {
let remaining = seconds;
const timerEl = document.getElementById('countdown-timer');
const interval = setInterval(function() {
remaining--;
timerEl.textContent = remaining;
timerEl.style.transform = 'scale(1.2)';
setTimeout(() => timerEl.style.transform = 'scale(1)', 200);
if (remaining <= 5) {
timerEl.classList.add('timer-urgent');
}
if (remaining <= 0) {
clearInterval(interval);
}
}, 1000);
}
</script>
Let us break down the key parts of this code:
new SockJS('/ws')— Creates a connection to the WebSocket endpoint you configured in the Spring BootWebSocketConfigclass. SockJS handles the initial handshake and any fallback protocols automatically.Stomp.over(socket)— Wraps the SockJS connection with the STOMP protocol, giving you the subscribe/send messaging API.stompClient.subscribe('/topic/game/' + joinCode, ...)— This subscribes to messages for a specific game. The join code makes each game's message channel unique. Players in game XK7M do not receive messages from game AB3Q.stompClient.send('/app/game/...')— Sends a message to the server. The/appprefix routes it to a@MessageMappingmethod in your controller.- The
switchstatement — Handles different message types by routing each to the appropriate display function. This is the core of the client-side game logic.
CSS Animations
Animations make the game feel alive. Two critical animations are the countdown timer pulse and score change effects. Use CSS transitions for smooth, performant animations:
/* Countdown timer pulse */
.countdown-timer {
font-size: 3rem;
font-weight: bold;
text-align: center;
transition: transform 0.2s ease, color 0.3s ease;
}
.countdown-timer.timer-urgent {
color: #e74c3c;
animation: pulse 0.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.3); }
}
/* Answer buttons */
.answer-btn {
width: 100%;
padding: 1rem;
margin: 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.answer-btn:active {
transform: scale(0.95);
}
.answer-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Score change animation */
.score-value {
display: inline-block;
transition: transform 0.3s ease;
}
.score-value.score-bump {
animation: scoreBump 0.4s ease;
}
@keyframes scoreBump {
0% { transform: scale(1); color: inherit; }
50% { transform: scale(1.4); color: #2ecc71; }
100% { transform: scale(1); color: inherit; }
}
Mobile Responsive Design
Most players will be on their phones. The answer buttons need to be large enough to tap easily. The text needs to be readable. The layout needs to work on screens as small as 320px wide. Design for mobile first, then let larger screens benefit naturally:
/* Mobile-first: designed for phones */
.game-container {
max-width: 600px;
margin: 0 auto;
padding: 1rem;
}
.answer-btn {
min-height: 60px; /* Easy to tap */
font-size: 1rem;
touch-action: manipulation; /* Prevents double-tap zoom */
}
.join-code-display {
font-size: 4rem;
font-weight: 900;
letter-spacing: 0.5rem;
text-align: center;
font-family: monospace;
padding: 2rem;
background: var(--bg-secondary);
border-radius: 12px;
}
/* Scoreboard layout */
.scoreboard-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.scoreboard-rank {
font-weight: 700;
width: 2rem;
}
.scoreboard-name {
flex: 1;
margin-left: 0.75rem;
}
/* Large screens: host view */
@media (min-width: 768px) {
.join-code-display {
font-size: 6rem;
}
.host-scoreboard {
font-size: 1.2rem;
}
}
The touch-action: manipulation CSS property is a subtle but important detail. On mobile browsers, tapping twice quickly triggers a zoom. In a quiz game where players are frantically tapping answers, the last thing you want is the screen zooming in accidentally. This property disables that behavior specifically on the answer buttons.
6. Adding Questions
A quiz game is only as good as its questions. You are going to build a question bank of 20 to 30 programming trivia questions drawn from everything you have learned in the Coders Farm curriculum. This is clever for two reasons: first, the questions are relevant and interesting for the people who will play this game (your classmates). Second, it turns the game into a sneaky study tool. Players are reviewing HTML, CSS, JavaScript, Java, SQL, and security concepts while competing for points.
Store the questions as a list of Question objects. Each question has the question text, four options, the index of the correct answer, and a category tag. Here is a sample of the question bank:
private List<Question> loadQuestions() {
List<Question> questions = new ArrayList<>();
// HTML Questions
questions.add(new Question(
"What does HTML stand for?",
new String[]{"Hyper Text Markup Language", "High Tech Modern Language",
"Home Tool Markup Language", "Hyperlinks and Text Markup Language"},
0, "HTML"
));
questions.add(new Question(
"Which HTML tag is used to create a hyperlink?",
new String[]{"<link>", "<a>", "<href>", "<url>"},
1, "HTML"
));
questions.add(new Question(
"Which HTML element is used to define the largest heading?",
new String[]{"<heading>", "<h6>", "<head>", "<h1>"},
3, "HTML"
));
// CSS Questions
questions.add(new Question(
"Which CSS property controls the text size?",
new String[]{"text-size", "font-size", "text-style", "font-weight"},
1, "CSS"
));
questions.add(new Question(
"What does the CSS box model consist of?",
new String[]{"Header, body, footer, sidebar", "Content, padding, border, margin",
"Width, height, color, font", "Display, position, float, clear"},
1, "CSS"
));
questions.add(new Question(
"Which CSS property is used to make a flex container?",
new String[]{"flex: 1", "display: flexbox", "display: flex", "flex-direction: row"},
2, "CSS"
));
// JavaScript Questions
questions.add(new Question(
"Which keyword declares a variable that cannot be reassigned?",
new String[]{"var", "let", "const", "final"},
2, "JavaScript"
));
questions.add(new Question(
"What is the output of typeof null in JavaScript?",
new String[]{"null", "undefined", "object", "NullType"},
2, "JavaScript"
));
questions.add(new Question(
"Which method converts a JSON string into a JavaScript object?",
new String[]{"JSON.stringify()", "JSON.parse()", "JSON.toObject()", "JSON.convert()"},
1, "JavaScript"
));
questions.add(new Question(
"What does the === operator check in JavaScript?",
new String[]{"Value only", "Type only", "Value and type", "Reference equality"},
2, "JavaScript"
));
// Java Questions
questions.add(new Question(
"Which Java keyword is used to create a subclass?",
new String[]{"implements", "inherits", "extends", "super"},
2, "Java"
));
questions.add(new Question(
"What is the default value of an int variable in Java?",
new String[]{"null", "0", "undefined", "-1"},
1, "Java"
));
questions.add(new Question(
"Which annotation marks a class as a Spring Boot REST controller?",
new String[]{"@Controller", "@RestController", "@Service", "@Component"},
1, "Java"
));
questions.add(new Question(
"What does JPA stand for?",
new String[]{"Java Persistence API", "Java Programming Architecture",
"Jakarta Page Assembly", "Java Platform Application"},
0, "Java"
));
questions.add(new Question(
"Which Spring annotation maps a method to handle HTTP POST requests?",
new String[]{"@GetMapping", "@RequestMapping", "@PostMapping", "@PutMapping"},
2, "Java"
));
// SQL Questions
questions.add(new Question(
"Which SQL keyword is used to retrieve data from a database?",
new String[]{"GET", "FETCH", "SELECT", "RETRIEVE"},
2, "SQL"
));
questions.add(new Question(
"What does the WHERE clause do in SQL?",
new String[]{"Sorts the results", "Filters which rows are returned",
"Groups rows together", "Limits the number of results"},
1, "SQL"
));
questions.add(new Question(
"Which SQL statement is used to add a new row to a table?",
new String[]{"ADD ROW", "INSERT INTO", "CREATE ROW", "APPEND TO"},
1, "SQL"
));
// Database Questions
questions.add(new Question(
"What is a primary key in a database?",
new String[]{"The first column in every table", "A unique identifier for each row",
"The most important data field", "A password to access the table"},
1, "Databases"
));
questions.add(new Question(
"What is the purpose of a foreign key?",
new String[]{"To encrypt sensitive data", "To link rows between two tables",
"To auto-increment IDs", "To prevent duplicate entries"},
1, "Databases"
));
questions.add(new Question(
"Which ddl-auto setting is safest for production?",
new String[]{"update", "create-drop", "create", "none or validate"},
3, "Databases"
));
// Security Questions
questions.add(new Question(
"What is the purpose of hashing a password?",
new String[]{"To make it shorter", "To store it securely so the original cannot be recovered",
"To encrypt it for sending over the network", "To check if it contains special characters"},
1, "Security"
));
questions.add(new Question(
"What does HTTPS add to HTTP?",
new String[]{"Speed improvements", "Encryption via TLS/SSL",
"Better error messages", "Larger file upload limits"},
1, "Security"
));
questions.add(new Question(
"What is SQL injection?",
new String[]{"A way to speed up database queries",
"An attack where malicious SQL is inserted through user input",
"A tool for importing data into databases",
"A method for joining multiple tables"},
1, "Security"
));
// Bonus: General Programming
questions.add(new Question(
"What is an API?",
new String[]{"A programming language",
"An interface that allows software systems to communicate",
"A type of database", "A web browser feature"},
1, "General"
));
questions.add(new Question(
"What does JSON stand for?",
new String[]{"JavaScript Object Notation", "Java Standard Object Names",
"JavaScript Online Network", "Java Serialized Object Notation"},
0, "General"
));
// Shuffle the questions for variety each game
Collections.shuffle(questions);
return questions;
}
The question bank covers every major topic from the curriculum:
- HTML — elements, tags, structure
- CSS — properties, box model, flexbox
- JavaScript — variables, types, operators, JSON
- Java — inheritance, default values, Spring annotations, JPA
- SQL — SELECT, WHERE, INSERT
- Databases — primary keys, foreign keys, ddl-auto
- Security — hashing, HTTPS, SQL injection
- General — APIs, JSON
Notice the Collections.shuffle(questions) at the end. Every game gets the questions in a different random order, so even if people play multiple times, it feels fresh. You can also easily add more questions over time — the more questions in the bank, the more variety in each game.
7. Polish & Play
A working game is a great accomplishment. A polished game is what makes people want to play again. This final section covers the details that separate a prototype from a product.
Podium Animation
When the game ends, do not just show a list of scores. Create a podium for the top three players. The podium is one of those small touches that makes the game feel complete and celebration-worthy:
/* Winner podium layout */
.podium {
display: flex;
justify-content: center;
align-items: flex-end;
gap: 1rem;
margin: 2rem 0;
min-height: 300px;
}
.podium-place {
display: flex;
flex-direction: column;
align-items: center;
animation: podiumRise 0.8s ease-out forwards;
opacity: 0;
}
.podium-place:nth-child(1) { animation-delay: 0.6s; } /* 2nd place */
.podium-place:nth-child(2) { animation-delay: 1.0s; } /* 1st place */
.podium-place:nth-child(3) { animation-delay: 0.3s; } /* 3rd place */
.podium-bar {
width: 100px;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 900;
color: white;
}
.podium-place-1 .podium-bar {
height: 200px;
background: linear-gradient(to top, #f1c40f, #f39c12);
}
.podium-place-2 .podium-bar {
height: 150px;
background: linear-gradient(to top, #bdc3c7, #95a5a6);
}
.podium-place-3 .podium-bar {
height: 110px;
background: linear-gradient(to top, #e67e22, #d35400);
}
.podium-name {
font-weight: 700;
margin-top: 0.5rem;
font-size: 1.1rem;
}
.podium-score {
color: var(--text-muted);
font-size: 0.9rem;
}
@keyframes podiumRise {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
The podium animates into view with a staggered delay. Third place rises first, then second, then first — building anticipation. The gold, silver, and bronze color scheme is instantly recognizable. The bars are different heights so the visual hierarchy is immediate. This is the kind of detail that makes people say "that is so cool" and want to play again.
Play Again
After the game ends, add a Play Again button on the host's screen. When clicked, it creates a new game with a fresh join code and returns to the lobby. The key implementation detail: do not try to reset the existing game object. Create a brand new one. This avoids subtle bugs from leftover state (old player lists, stale timers, partial scores). A fresh game object is clean and predictable.
function playAgain() {
// Create a brand new game on the server
fetch('/api/game/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostName: currentHostName })
})
.then(response => response.json())
.then(data => {
// Unsubscribe from the old game channel
if (currentSubscription) {
currentSubscription.unsubscribe();
}
// Update the join code display
currentJoinCode = data.joinCode;
document.getElementById('join-code-display').textContent = data.joinCode;
// Subscribe to the new game channel
currentSubscription = stompClient.subscribe(
'/topic/game/' + data.joinCode,
handleGameMessage
);
// Switch back to lobby view
showView('lobby');
});
}
Testing with Multiple Browser Windows
You do not need multiple physical devices to test your multiplayer game. Open your browser and create three or four tabs or windows:
- Window 1 — The host. Create a game and note the join code.
- Window 2 — Player one. Enter the join code and a name.
- Window 3 — Player two. Enter the same join code and a different name.
- Window 4 — Player three. Same code, different name.
Arrange the windows so you can see all of them at once. Start the game from the host window and watch questions appear on all player windows simultaneously. Submit answers from different player windows and watch the host's scoreboard update in real time. This is the moment where everything you built comes together — real-time communication happening right in front of you across multiple browser windows.
- Can multiple players join with the same code?
- Does the question appear on all screens at the same time?
- Does submitting an answer disable the buttons (preventing double-submit)?
- Do scores calculate correctly (faster answers earn more points)?
- Does the timer work? Does it auto-advance when time runs out?
- Does the scoreboard sort correctly (highest score at the top)?
- Does the game end properly after the last question?
- Does the podium show the correct top three?
- Does Play Again create a fresh game with a new code?
- What happens if a player disconnects mid-game?
Bring It to Coders Farm
This is not a project that should live only on your laptop. This game was designed to be played with real people. Here is how to run it at a Coders Farm meetup:
- Deploy your Spring Boot app (even locally on the same WiFi network is fine). Find your computer's local IP address (
ipconfigon Windows,ifconfigon Mac/Linux). - Connect a laptop to a projector or large screen. Open the host view in the browser.
- Tell players to open their phone browsers and go to
http://YOUR_IP:8080. - Display the join code on the big screen. Watch players join in real time.
- Start the game. Watch the room come alive as people race to answer questions.
There is something deeply satisfying about building software that other people use right in front of you. When a room full of people are playing your game, answering questions, cheering when they get the right answer, groaning when the timer runs out — that is the feeling of being a software engineer. You built something real. You built something people enjoy. And you did it with WebSockets, Spring Boot, JavaScript, and everything you have learned at Coders Farm.
Knowledge Check
1. What is the key difference between HTTP and WebSockets?
2. Why does the game use a ConcurrentHashMap instead of a regular HashMap to store active games?
HashMap simultaneously, it can cause data corruption, lost updates, or crashes. ConcurrentHashMap is designed for exactly this scenario — it allows safe concurrent access from multiple threads without requiring you to write manual synchronization code.3. In the STOMP configuration, what is the purpose of config.enableSimpleBroker("/topic")?
/topic (like /topic/game/XK7M), the broker delivers that message to every client that has subscribed to that destination. This is how the server broadcasts questions, scores, and game events to all players in a specific game without sending individual messages to each one.Deliverable
When you complete this side quest, you should have a working multiplayer quiz game that is playable from multiple phones connected via WebSockets. Players join by entering a four-character code on their phone browsers. The host controls the game from a large screen. Questions appear in real time, answers are submitted and scored instantly, and a winner is crowned at the end with a podium animation. The game includes a bank of 20+ programming trivia questions covering the Coders Farm curriculum, making it both a fun competitive game and an effective review tool. Test it with multiple browser windows and then bring it to a Coders Farm meetup to play with real people.
Finished this side quest?