devlog/content/posts/buzzer-game.md

5.2 KiB
Raw Permalink Blame History

+++ date = '2024-11-24T15:23:50+01:00' draft = true title = 'Classroom Buzzer App' tags = ["education","golang","websockets","concurrency"] +++

I started working on a new project today.

The client is an educational services provider and wants me to develop a tool to facilitate in-person group activities. This tool should:

  • provide a platform which students can log into from a mobile device, in a frictionless process that takes seconds
  • allow a teacher to assign groups and pairs in
  • allow the teacher to dynamically reassign groups without repeating combinations
  • implement a simple score-keeping functionality
  • be able to run a 'buzzer' game
  • have a clean, appealing, and user-friendly UI

A quick read of this brief should make it clear that what is required here is a server capable of handling multiple, live, two-way connections. The server needs to be able to update the clients whenever the teacher wants to shuffle the groups, and the buzzer game requires that when a student 'buzzes', state is propagated via the server to all other clients. The solution to this problem is websockets, and a server capable of handling concurrency.

Now I'm relatively new to both of these topics, but my project GoPaper made use of concurrent goroutines to implement a wallpaper daemon, so I'll be building off that knowledge and developing the server side of this project in Go.

WebSockets in Go

It is possible to build a websocket framework entirely from scratch in Go, but seeing as I don't hate myself quite that much, I'll be using the framework Gorilla.

Getting started: running concurrent websocket connections

package main

import (
	"log"
	"net/http"
	"sync"

	"github.com/gorilla/websocket"
)

type webSocketHandler struct {
	upgrader websocket.Upgrader
}

// GLOBALS
var connections int = 0
var wg sync.WaitGroup

func (wsh webSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	//upgrade http connection to websocket
	connection, err := wsh.upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Printf("Error while upgrading connection to websocket: %s", err)
		return
	}
	connections++
	log.Printf("New websocket connection. There are now %v\n", connections)
	wg.Add(1)
	go func() {
		for {
		//actually handle the websocket connection here
		}
	}()
}

func main() {
	webSocketHandler := webSocketHandler{
		upgrader: websocket.Upgrader{},
	}
	var port string = "8080"
	http.Handle("/", webSocketHandler)
	wg.Add(1)
	log.Printf("listening on port %s...\n", port)
	log.Fatal(http.ListenAndServe("localhost:8080", nil))
	wg.Wait()
}

In this quick-and-dirty bit of code, we create a websocket handler that upgrades an http connection to a websocket connection, and then launches a goroutine. The goroutine doesn't do anything yet, but running ´websocat´ from multiple terminals shows us that we are now capable of handling multiple concurrent websocket connections 👍.

websocat ws://localhost:8080/ 
New websocket connection. There are now 1.
New websocket connection. There are now 2.
New websocket connection. There are now 3.

We know the connections have been made as either websocat or our Go app would error out otherwise. We should certainly do something to handle closed connections, as this code is a memory leak waiting to happen as things stand, but as a proof of concept, it works.

Now, time to actually do something with these connections! Let's add some logic to that goroutine.

func (wsh webSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	//upgrade http connection to websocket
	connection, err := wsh.upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Printf("Error while upgrading connection to websocket: %s", err)
		return
	}
	connections++
	log.Printf("New websocket connection. There are now %v\n", connections)
	wg.Add(1)
	go func() {
		defer connection.Close()
		for {
			msgType, message, err := connection.ReadMessage()
			if err != nil {
				log.Printf("Error trying to read message from client: %s", err)
				return
			}
			if msgType == websocket.BinaryMessage {
				err = connection.WriteMessage(websocket.TextMessage, []byte("this server does not support binary messages"))
				if err != nil {
					log.Printf("Error trying to send message to client: %s", err)
				}
				return
			}
			log.Printf("received message from client: %s", message)
			err = connection.WriteMessage(websocket.TextMessage, []byte("Message received!"))
		}
	}()
}

Here's what we just added:

  • The defer keyword closes the connection if/when the goroutine returns. In its current state, that would only happen in the case of an error.
  • connection.ReadMessage() blocks until a message is received from the client
  • If the client tries to send binary data, we bounce an error message back to the client, log an error on the server and return from the goroutine.
  • If the message is text, we log it on the server and send a thank-you message back to the client. Testing this again with multiple instances of websocat shows that everything is working as it should. We have established two-way channels of communication between a server and multiple clients! 🥳

In my next post, I'll begin implementing the business logic.