Quizzer
The Quizzer is a web application that can be used in bars, sports canteens and maybe even prisons to play quizzes as a team. A pub quiz, basically.
In the final assessment of my web development course it was decided to build a pub quiz that could be played with family during the holiday break. The student would have to deal with multiple teams, rounds and questions.
The project was build with two Single Page Applications, one for the quiz master and one for the teams. The SPAs are build with React and to store the quiz state Redux was used. AntD was used as frontend framework with Formik to handle all forms.
The backend was build using Express on Node.js and MongoDB with Mongoose. Because the UI applications and backend are so closely connected I decided to create a monorepo. The bonus of having a monorepo is that I could share components between applications.
Because of the monorepo schema validation was easy. I could use the same schemas in the UI as in the backend. This way I could always be sure the correct schema was provided. I made use of Formik
and it had a great implementation in combination with Yup
. Something I would use again in the future.
// common/../quiz.ts
const QuizSchema: ObjectSchema<QuizSchema> = object({
name: string()
.min(4)
.required(),
language: string()
.oneOf(languages)
.notRequired(),
password: string()
.min(8)
.notRequired(),
}).defined();
Challenges
The UI of the quiz masters and teams need to run in sync to make the pub quiz a pleasant expierence. To do this I used WebSockets to send a notification to the UI applications that there was a possible update ready. The UI then requested the new quiz state. Because the UI handles what is shown to the user, the backend does not have to deal with the UI status.
To keep the backend scalable I opted to use the MongoDB
change stream cursor to watch the quiz table. When an update is recieved the backend sends a message to all WebSocket connections that are listening for that quiz ID.
// socket.ts
QuizModel.watch().on('change', change => {
if (change.operationType === 'update') {
const quizId = change.documentKey._id.toString()
sendUpdate(quizId)
if (change.updateDescription.updatedFields.isFinished) {
disconnectAllFromQuiz(quizId)
}
}
The quiz state is stored in a Redux store. This state is watched by the views and they decide on which conditions to show the view type. In a future project I may want to refactor this part to improve visibility.
// quiz.tsx
if (!quiz || quiz._id !== quizId) {
return <Spin />
} else if (quiz.isFinished) {
return <ScoreBoard quizId={quiz._id!} />
} else if (!quiz.rounds?.length && isApprovingTeams) {
return <ApproveTeamsComponent />
} else if (!quiz.rounds?.length && !isApprovingTeams) {
return <CreateRoundComponent />
} else if (quiz.rounds?.length) {
const round = quiz!.rounds![quiz!.rounds!.length - 1];
if (!round.questions?.length) {
return <AddQuestionRoundComponent />
} else if (round.questions.length === questionsLimit && round.questions[round.questions.length - 1].isFinished) {
return <CreateRoundComponent />
} else if (!round.questions[round.questions.length - 1].isFinished) {
return <ApproveAnswersComponent />
} else {
return <AddQuestionRoundComponent />
}