Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save LeartS/2bdd39087f4eeb5a143d757576508f5a to your computer and use it in GitHub Desktop.
Save LeartS/2bdd39087f4eeb5a143d757576508f5a to your computer and use it in GitHub Desktop.

Solving brainteasers by using Livebook, Kino & VegaLite! (prebaked)

Mix.install([
  # Interactive cells in Livenotebooks
  {:kino, "~> 0.6.0"},
  # VegaLite interactive cells. VegaLite is a very nice charting library/framework
  {:kino_vega_lite, "~> 0.1.1"}
])

alias VegaLite, as: Vega

The puzzle

Someone proposes you a betting game: "You pay £2 to play. You can choose a number from 1 to 6. Then I'll roll 3 dice and I'll pay you £3 for each dice that shows the number you chose".

Assume standard dice and no cheating involved. Purely from a probability perspective, should you play the game? I.e. is the game in your favor, in the dealer favor, or fair?

This puzzle can obviously be solved mathematically, and there is actually a nice solution that doesn't require any calculations or probability theory, but using Livebook and animated charts is clearly a cooler option.

Let's simulate a single round

Simple, standard Elixir. But in Livebook.

Simulating a single round obviously doesn't give us the solution to the problem. But we can then use this as a building block to simulate a huge number of rounds and hopefully get something out of it.

defmodule Game do
  @buyin 2
  @payout 3

  def play() do
    # player chooses a random number
    choice = Enum.random(1..6)
    # Dealer rolls 3 dice
    dice = for _ <- 1..3, do: Enum.random(1..6)
    # Each dice with the player choice is a winner
    n_winners = Enum.count(dice, fn d -> d == choice end)
    # players pays @buyin to play, and gets @payout per each winner dice
    profit = n_winners * @payout - @buyin

    %{
      profit: profit,
      choice: choice,
      dice: dice
    }
  end
end

Let's test our implementation of the game:

Game.play()

Let's make it interactive with Kino

Let's bring Kino into the mix to add some interactivity and let us simulate several rounds of our game by simply pressing a button.

# A button that we will use to interactively trigger a new round
play_button = Kino.Control.button("Play a round") |> Kino.render()
# An output frame on which we'll output the results of each round
output = Kino.Frame.new() |> Kino.render()

# We stream events from the input button, and whenever it is pressed
# we simulate a new round. We keep our running balance as the stream state.
Kino.Control.stream(play_button, 10, fn _event, balance ->
  %{profit: profit, choice: choice, dice: dice} = Game.play()

  # We can dynamically update the output frame by rendering some markdown content
  Kino.Frame.render(
    output,
    Kino.Markdown.new("You played **#{choice}** and the house rolled **#{inspect(dice)}**.\
        You **#{if profit > 0, do: "won", else: "lost"} £#{abs(profit)}**.\
        You now have **£#{balance + profit}** left")
  )

  balance + profit
end)

Kino.nothing()

Let's make it run automatically and chart the results with VegaLite!

Pressing the button is tiring! Let's have the code do that for us, and use a visual output that can let us understand what's going on when several hundreds rounds are played.

In theory:

  • If the game favors the dealer, we will be losing money on average and get more and more in debt. The line will be trending down.
  • If the game is fair, we will stay stable. The line should be mostly horizontal (with some jumps in both directions due to chance)
  • If the game is in our favor, we will be making money. The line will be clearly trending up.
line_chart =
  Vega.new(width: 500, height: 300)
  |> Vega.mark(:line)
  # Potentially, instead of us explicitly calculating the running balance in our
  # periodic loop, we can have VegaLite calculate it for us.
  # |> Vega.transform(window: [[op: "sum", field: "profit", as: "balance"]])
  |> Vega.encode_field(:x, "rounds_played",
    type: :quantitative,
    scale: [nice: false],
    axis: [title: "Rounds Played"]
  )
  |> Vega.encode_field(:y, "balance",
    type: :quantitative,
    scale: [domain: %{"unionWith" => [-100, 100]}],
    axis: [title: "Balance"]
  )
  |> Kino.VegaLite.new()
  |> Kino.render()

# Simulate a new round every 10 milliseconds and add the new datapoint
# to the chart
Kino.VegaLite.periodically(line_chart, 10, {1, 0}, fn
  {round, balance} when round < 3_500 and abs(balance) < 1000 ->
    %{profit: profit} = Game.play()

    Kino.VegaLite.push(
      line_chart,
      %{
        "rounds_played" => round,
        "profit" => profit,
        "balance" => balance
      },
      window: 1000
    )

    {:cont, {round + 1, balance + profit}}

  _ ->
    :halt
end)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment