Skip to content

Instantly share code, notes, and snippets.

@JD557

JD557/1-quiz.md Secret

Last active November 1, 2025 16:00
Show Gist options
  • Select an option

  • Save JD557/4bd5b7acd53a08e8a9a306a2bde57a97 to your computer and use it in GitHub Desktop.

Select an option

Save JD557/4bd5b7acd53a08e8a9a306a2bde57a97 to your computer and use it in GitHub Desktop.
Scala quiz

Scala Quiz

Here's a quick example of a Quiz game in Scala.

You can run this with scala-cli by pointing it to this gist with

scala-cli --power --enable-markdown https://gist.github.com/JD557/4bd5b7acd53a08e8a9a306a2bde57a97

Screenshot of the game

Libraries

This project uses the Scala toolkit, InterIm and Minart.

The Scala toolkit requires at least Java 11 for the HTTP requests, so we are using that.

//> using resourceDir ./
//> using jvm "adopt:11"
//> using scala "3.3"
//> using toolkit default
//> using dep "eu.joaocosta::minart::0.6.5"
//> using dep "eu.joaocosta::interim::0.2.0"

import scala.concurrent.Future
import sttp.client4.Response
import sttp.client4.quick.*
import upickle.default.*
import java.util.Base64
import java.text.Normalizer

import eu.joaocosta.interim.*
import eu.joaocosta.interim.InterIm.*
import eu.joaocosta.minart.backend.defaults.given
import eu.joaocosta.minart.graphics.image.*
import eu.joaocosta.minart.graphics.{Color => MinartColor, *}
import eu.joaocosta.minart.input.*
import eu.joaocosta.minart.runtime.*

API access

We'll get our quiz questions from the Open Trivia Database.

To do that, we'll query the backend with sttp and parse the response with ujson. The API returns all strings encoded as Base64, so we add a Question#decode method to handle the decoding. We'll also get rid of non-ASCII characters, since we don't have those in our font.

val base64Decoder = Base64.getDecoder()
def decodeAndNormalize(str: String): String =
  Normalizer.normalize(new String(base64Decoder.decode(str)), Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "");

case class Question(question: String, correct_answer: String, incorrect_answers: List[String]) derives ReadWriter:
  def decode =
    copy(
      question = decodeAndNormalize(question),
      correct_answer = decodeAndNormalize(correct_answer),
      incorrect_answers = incorrect_answers.map(decodeAndNormalize)
    )

val questions: List[Question] =
  val response: Response[String] = quickRequest
    .get(uri"https://opentdb.com/api.php?amount=48&encode=base64")
    .send()
  read[List[Question]](
    ujson.read(response.body)("results")
  ).map(_.decode)

Game logic

Next we define the game logic.

First, we have our player model. Each player has a name, a number of correct answers and the total answers.

We also define some helper methods to add a correct or incorrect answer.

case class Player(name: String, correctAnswers: Int = 0, totalAnswers: Int = 0):
  def addCorrectAnswer =
    copy(correctAnswers = correctAnswers + 1, totalAnswers = totalAnswers + 1)
  def addIncorrectAnswer =
    copy(totalAnswers = totalAnswers + 1)

Then we define our game state, which is just a sequence of questions and players. For simplicity, we'll assume that the current question and current player are always the head of the list.

case class GameState(
    questions: List[Question] = questions,
    players: List[Player] = Nil
):
  val currentQuestion = questions.head.question
  val currentOptions =
    scala.util.Random.shuffle(questions.map(q => q.correct_answer :: q.incorrect_answers).head.toVector)
  val correctAnswer = questions.head.correct_answer

  def fillPlayers(numPlayers: Int) =
    copy(players = (0 until numPlayers).map(idx => Player(s"Player ${idx + 1}")).toList)
  def addCorrectAnswer =
    copy(questions.tail :+ questions.head, players.tail :+ players.head.addCorrectAnswer)
  def addIncorrectAnswer =
    copy(questions.tail :+ questions.head, players.tail :+ players.head.addIncorrectAnswer)
  def submitAnswer(answer: String) =
    if (answer == correctAnswer) addCorrectAnswer
    else addIncorrectAnswer

UI

First, we define some constants that we need for our user interface.

val windowWidth  = 800
val windowHeight = 600

val spleenLarge = Font("spleen-12-24", 24, 12)
val spleenSmall = Font("spleen-8-16", 16, 8)

val uiContext = new UiContext() // Internal state used by InterIm

Next, we'll have two interfaces:

  • If the game state has no players, we want to select the number of players
  • Otherwise, we present a question and buttons with the answers

Player selection

For the player selection UI, we'll show a text asking "How many players?" and 4 buttons (1 to 4 players)

If a button is clicked, we update our state with the required number of players

def playerSelection(inputState: InputState, gameState: GameState) =
  ui(inputState, uiContext):
    dynamicRows(Rect(0, 0, windowWidth, windowHeight).shrink(10), padding = 0): row ?=>
      text(
        area = row(128),
        color = Color(255, 255, 255),
        message = "How many players?",
        font = spleenLarge,
        horizontalAlignment = centerHorizontally,
        verticalAlignment = centerVertically
      )
      grid(row(maxSize), numRows = 2, numColumns = 2, padding = 5): cells =>
        cells.flatten.zipWithIndex
          .flatMap: (cell, idx) =>
            button(
              id = s"answer-$idx",
              label = (idx + 1).toString,
              skin = skins.ButtonSkin.lightDefault.copy(font = spleenLarge)
            )(area = cell)(gameState.fillPlayers(idx + 1))
          .headOption
          .getOrElse(gameState)

In Game

During the game, our layout is very similar, but now we show:

  • A text with the question
  • The player scores
  • Buttons with the answers

If a player presses a button, we update the score and go to the next player/question.

def inGame(inputState: InputState, gameState: GameState) =
  ui(inputState, uiContext):
    dynamicRows(Rect(0, 0, windowWidth, windowHeight).shrink(10), padding = 0): row ?=>
      // Question
      text(
        area = row(128),
        color = Color(255, 255, 255),
        message = gameState.currentQuestion,
        font = spleenLarge,
        horizontalAlignment = centerHorizontally,
        verticalAlignment = centerVertically
      )
      // Scores
      columns(row(32), numColumns = 4, padding = 5):
        gameState.players.zipWithIndex.sortBy(_._1.name)
          .foreach: (player, idx) =>
            text(
              area = summon,
              color = if (idx == 0) Color(150, 255, 150) else Color(255, 255, 255),
              message = s"${player.name} - Score ${player.correctAnswers} / ${player.totalAnswers}",
              font = spleenSmall,
              horizontalAlignment = alignLeft,
              verticalAlignment = centerVertically
            )
        // Answers
        grid(row(maxSize), numRows = 2, numColumns = 2, padding = 5): cells =>
          cells.flatten
            .zip(gameState.currentOptions)
            .flatMap: (cell, option) =>
                button(
                  id = s"answer-$option",
                  label = option,
                  skin = skins.ButtonSkin.lightDefault.copy(font = spleenLarge)
                )(cell)(gameState.submitAnswer(option))
            .headOption
            .getOrElse(gameState)

Tying it all together

def application(inputState: InputState, gameState: GameState) =
  if (gameState.players.isEmpty) playerSelection(inputState, gameState)
  else inGame(inputState, gameState)

MinartBackend.run(GameState())(application)

Graphical Backend

For the graphical backend, we use Minart to interpret the operations and run the app loop.

object MinartBackend:

  trait MinartFont:
    def charWidth(char: Char): Int
    def coloredChar(char: Char, color: MinartColor): SurfaceView

  case class BitmapFont(file: String, width: Int, height: Int, fontFirstChar: Char = '\u0000') extends MinartFont:
    private val spriteSheet = SpriteSheet(Image.loadBmpImage(Resource(file)).get, width, height)
    def charWidth(char: Char): Int = width
    def coloredChar(char: Char, color: MinartColor): SurfaceView =
      spriteSheet.getSprite(char.toInt - fontFirstChar.toInt).map {
        case MinartColor(255, 255, 255) => color
        case c                          => MinartColor(255, 0, 255)
      }

  case class BitmapFontPack(fonts: List[BitmapFont]):
    val sortedFonts = fonts.sortBy(_.height)
    def withSize(fontSize: Int): MinartFont =
      val baseFont = sortedFonts.filter(_.height <= fontSize).lastOption.getOrElse(sortedFonts.head)
      if (baseFont.height == fontSize) baseFont
      else
        val scale = fontSize / baseFont.height.toDouble
        new MinartFont:
          def charWidth(char: Char): Int = (baseFont.width * scale).toInt
          def coloredChar(char: Char, color: MinartColor): SurfaceView = baseFont.coloredChar(char, color).scale(scale)

  // Spleen font by Frederic Cambus: https://github.com/fcambus/spleen
  private val spleen = BitmapFontPack(List(
    BitmapFont("spleen-8-16.bmp", 8, 16, ' '),
    BitmapFont("spleen-12-24.bmp", 12, 24, ' ')
  ))

  private def getInputState(canvas: Canvas): InputState = InputState(
    canvas.getPointerInput().position.map(_.x).getOrElse(0),
    canvas.getPointerInput().position.map(_.y).getOrElse(0),
    canvas.getPointerInput().isPressed,
    ""
  )

  private def renderUi(canvas: Canvas, renderOps: List[RenderOp]): Unit =
    renderOps.foreach {
      case RenderOp.DrawRect(Rect(x, y, w, h), color) =>
        canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
      case op: RenderOp.DrawText =>
        val font = spleen.withSize(op.font.fontSize)
        op.asDrawChars.foreach { case RenderOp.DrawChar(Rect(x, y, _, _), color, char) =>
          val charSprite = font.coloredChar(char, MinartColor(color.r, color.g, color.b))
          canvas
            .blit(charSprite, BlendMode.ColorMask(MinartColor(255, 0, 255)))(x, y)
        }
      case RenderOp.Custom(Rect(x, y, w, h), color, _) =>
        canvas.fillRegion(x, y, w, h, MinartColor(color.r, color.g, color.b))
    }

  val canvasSettings =
    Canvas.Settings(
      width = windowWidth,
      height = windowHeight,
      title = "Quiz",
      clearColor = MinartColor(80, 110, 120)
    )

  def run[S](initialState: S)(body: (InputState, S) => (List[RenderOp], S)): Future[S] =
    AppLoop
      .statefulRenderLoop { (state: S) => (canvas: Canvas) =>
        val inputState = getInputState(canvas)
        canvas.clear()
        val (ops, newState) = body(inputState, state)
        renderUi(canvas, ops)
        canvas.redraw()
        newState
      }
      .configure(
        canvasSettings,
        LoopFrequency.hz60,
        initialState
      )
      .run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment