Christmas is a time of beatiful lid homes, christmas trees, gifts and… akward family dinners. Unfortunately mine tend to take ages, while sitting and talking about the weather, politics, and other <sarcasm> very exciting stuff </sarcasm>.
Let’s try something different
Well this year I’m going to build an interactive quiz where we’ll display questions and riddles on the tv screen while everyone can answer them using their own cellphones.
Since we want the whole family to play, we have multiple phones that will be connected to our backend service, while only one tv will be displaying the questions, timers, etc. Meaning we will have two different kind of clients, a player and observer.
Both will be connected to our websocket server which will hold the responsibility to align every client on the state of the quiz.
Setting up our websocket server
As we said we’re going to use a Spring Boot application for our websocket server, so let’s set that up.
@Bean public HandlerMapping webSocketHandlerMapping(){ Map<String, WebSocketHandler> urlMap = new HashMap<>(){{ put("/websocket", handler); }};
SimpleUrlHandlerMapping handlerMapping = new SimpleUrlHandlerMapping(); handlerMapping.setOrder(1); handlerMapping.setUrlMap(urlMap);
return handlerMapping; }
@Bean public WebSocketHandlerAdapter handlerAdapter(){ returnnew WebSocketHandlerAdapter(); } }
We can now define our websocket handler, which allow us to start communicating to clients. Since we’re using Webflux we can only send data to the client by using a Reactive Streams Publisher which we’ll implement shortly.
@Override public Mono<Void> handle(WebSocketSession session){ // TODO return Mono.empty() } }
Let’s handle player registration
Since uncle Joe is not as familiar with his new iPhone yet, we need to make sure losing connection is not an issue and we can reconnect with a new websocket connection to an existing player. We can achieve this by letting every client / phone send its own unique session id, and store it for future reconnects.
1
const socket = new WebSocket(`ws://${ip}:8081/websocket?session_id=${session_id}`);
Since the backend will now get a unique identifier for every player, we can connect a Sink to every client to allow sending messages to specific players.
@Override public Mono<Void> handle(WebSocketSession session){ String sessionId = getSessionId(session.getHandshakeInfo().getUri());
// Get stream of messages specific for this player / observer Flux<WebSocketMessage> messages = service.getMessages(sessionId) .map(session::textMessage);
// Get stream of messages coming from this specific player / observer Flux<WebSocketMessage> reading = session.receive() .doOnNext(message -> onMessage(message.getPayloadAsText(), sessionId)) ;
return session.send(messages).and(reading); }
We’ll create a MessagingService which sole responsibility is to keep track of the session Sinks and expose a method to send a message to a specific session.
publicvoidonMessage(Message wsMessage, String sessionId){ MessageType type = wsMessage.getType();
switch (type) { case OBSERVER_JOIN -> { observer.setSessionId(sessionId); } case PLAYER_JOIN -> { PlayerJoinMessage message = (PlayerJoinMessage) wsMessage;
Player player = Player.builder() .id(sessionId) .name(message.getName()) .score(0) .build();
quiz.register(player, sessionId);
// Notify the observer of a player join. observer.send(message); } case SHOW_QUESTION -> { // Observer requesting next question quiz.next(); } case ANSWER -> { // Player answering to question AnswerMessage message = (AnswerMessage) wsMessage; quiz.answer(sessionId, message.getAnswer(), message.getScore()); } case TIMEOUT -> { quiz.showQuestionResult(); } case SHOW_LEADERBOARD -> { // Observer requesting the leaderboard leaderboard.show(); } } } }
There you have it, the process of my own anti-boredom family pub quiz.
As you can see I’ve left the actual quiz, and leaderboard functionality out of this blogpost, but the full implementation details are available in the backend repository and front-end repository on Github.
Demo
Curious to how it all works together? Wait no longer, here is a short demo!