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
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.
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 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()
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)