Building CoinBet - Setup

40 minutes read

I discovered Elixir in 2020, mostly through the buzz around Phoenix LiveView and how it makes building scalable, full-stack web applications surprisingly straightforward. The idea that you could build interactive, real-time apps with a small team—and write minimal custom JavaScript was incredibly appealing. I was sold. I wanted to build something serious with it.

But for the next five years, I mostly watched from the sidelines. My work is in Elixir, but it focuses on backend systems—deep in real-time background processes, not even APIs.

Now, with LiveView hitting 1.0 and becoming more mature, I wanted to finally dive in. I set out to build something complex but relatable—an app that handles frequent updates, takes user input, and is actually fun to build. Naturally, I leaned into two things I already had a strong understanding of: cryptocurrencies and betting.

🪙🎲 Enter CoinBet

CoinBet lists popular cryptocurrencies like Bitcoin, Solana etc. and allows users to place bets on either the price going up or down within a specific duration. If the price direction is predicted correctly, the bet wins 2x the stake. Otherwise, the bet loses. There is also a possibility to call the exact price. If you’re feeling confident, you can set a target price for where you think an asset like Bitcoin or Solana will reach.

If you get the direction and your target is close, you’ll get bigger rewards depending on how accurate your guess is.

How it works

  • Guess the price direction → Get 2x your stake if you’re right.
  • Set a target price → If you’re close, you get up to 10x your stake.
  • We check how far off your target is (within a ±$2,000 range), and give you a bonus multiplier.

Payout Tiers

🎯 Distance from Target 💰 Multiplier
Within $0–$199 10x
$200–$499 7x
$500–$999 5x
$1,000–$1,499 3x
$1,500–$1,999 2x
$2,000+ 1x (just direction win)

If you get the direction wrong, you lose the bet — no matter how close the price was.

Example

Let’s say you bet $10 that Bitcoin will go up, and set a target price of $70,000:

  • BTC hits $69,880 → Only $120 off → 10x → you get $100
  • BTC hits $70,450 → $450 off → 7x → you get $70
  • BTC hits $71,300 → $1,300 off → 3x → you get $30
  • BTC hits $73,000 → $3,000 off → outside range → just 2x → you get $20
  • BTC drops to $67,000 → Wrong direction → you lose the bet

So if you’re not just lucky, but also precise, CoinBet rewards you big time. In the future, we might include a multiplayer feature and perhaps a leaderboard.

⚡ Heads up

This isn’t a LiveView tutorial. I’m assuming you already know your way around Elixir, Phoenix, and LiveView. The goal here is to document the journey, share real-world challenges, and hopefully spark some ideas — not to explain the basics.

Getting started

With the gameplay out of the way, let’s get some design ideas for the app. My favorite way to prototype new ideas is to use https://loveable.dev or any other natural language prompting tool. After about an hour of prompting, I ended up with a decent-looking app that sort of works.

Overall, I was pretty impressed with what Lovable generated—it looked good and gave me a solid foundation to build on. But there was a catch: Lovable outputs a React app, and since I’m building this with Phoenix LiveView, I had to translate the UI into LiveView components manually.

There’s no direct way to convert React components into Phoenix ones, so the process was a bit tedious. On the bright side, Lovable uses Tailwind CSS, which meant I could reuse most of the styles without much hassle.

Still, this hand-off took close to a week. Not a deal-breaker, but definitely something to keep in mind. Hopefully, down the line, tools like Lovable will add support for Phoenix and Elixir natively—it would make life a whole lot easier.

Icons

Phoenix ships with heroicons out of the box, but the AI generated app uses Lucide, and since I want the app to look very similar, I also explored how to use Lucide and maybe any custom icon directly in Phoenix. This was very straightforward and honestly, it was way easier than I expected.

First I included Lucide in the mix.exs deps

defp deps do    [
    ...,
    {:lucide,
      github: "lucide-icons/lucide",
      tag: "v0.265.0",
      sparse: "icons",
      app: false,
      compile: false,
      depth: 1},
    ...
  ]
end

Next, I created an assets/vendor/lucide.js file with a slightly modified Tailwind CSS plugin from assets/vendor/heroicons.js. The plugin looks like this:

const plugin = require("tailwindcss/plugin");  const fs = require("fs");
const path = require("path");

module.exports = plugin(function ({ matchComponents, theme }) {
  let iconsDir = path.join(__dirname, "../../deps/lucide/icons");
  let values = {};
  fs.readdirSync(iconsDir).forEach((file) => {
    if (file.endsWith(".svg")) {
      const name = path.basename(file, ".svg");
      values[name] = { name, fullPath: path.join(iconsDir, file) };
    }
  });

  matchComponents(
    {
      lucide: ({ name, fullPath }) => {
        let content = fs
          .readFileSync(fullPath, "utf8")
          .replace(/\r?\n|\r/g, "");

        content = encodeURIComponent(content);
        let size = theme("spacing.6");

        return {
          [`--lucide-${name}`]: `url("data:image/svg+xml;utf8,${content}")`,
          "-webkit-mask-image": `var(--lucide-${name})`,
          "mask-image": `var(--lucide-${name})`,
          "mask-repeat": "no-repeat",
          "mask-position": "center",
          "mask-size": "100% 100%",
          "-webkit-mask-repeat": "no-repeat",
          "-webkit-mask-position": "center",
          "-webkit-mask-size": "100% 100%",
          "background-color": "currentColor",
          display: "inline-block",
          width: size,
          height: size,
          "vertical-align": "middle",
        };
      },
    },
    { values },
  );
});

Then I included the plugin in the assets/css/app.css file which now looks like this:

...  @plugin "../vendor/heroicons";
@plugin "../vendor/lucide";
...

Finally, I added another function clause for CoinBetWeb.CoreComponents.icon/1 that looks like this:

  def icon(%{name: "lucide-" <> _} = assigns) do      ~H"""
    <span class={[@name, @class]} />
    """
  end

Lucide icons can now be used in the app like this:

<.icon name="lucide-bar-chart-2" class="text-blue-700 size-4" />

The cool thing about this approach is that the icons are loaded only once in the stylesheet, and then referenced using css classes. Pretty neat!

User Interface

I created a new live route in my router.ex file as the entry point to my application. Eventually, I’ll make it the default / and also nest it in a live_session but for now this works to get things up and running.

scope "/", CoinBetWeb do    pipe_through :browser

  # live "/", CryptoLive
  live "/crypto", CryptoLive
end

The CryptoLive itself is simple. It wraps a Phoenix 1.8 app layout with the markup from the react app. The only difference is, each crypto card is now a live component. This will help each coin manage its state and gives us possibility to send price updates to each coin individually.

<Layouts.app flash={@flash}>    <div class="flex justify-between items-center mb-6">
    <div class="flex items-center space-x-2">
      <h1 class="text-3xl font-bold">CoinBet</h1>
      <.button variant="ghost" size="sm">
        <.link href="/how-it-works" class="flex items-center text-muted-foreground">
          <.icon name="lucide-help-circle" class="mr-2 h-4 w-4" /> How it Works
        </.link>
      </.button>
    </div>

    <.button variant="ghost" class="flex items-center border border-gray-300">
      <.icon name="lucide-users" class="mr-2 h-4 w-4" /> Create / Join Game
    </.button>
  </div>

  <div class="flex flex-col lg:flex-row gap-8">
    <div class="flex-1 space-y-8">
      <div class="space-y-4">
        <div class="flex justify-between items-center">
          <h2 class="text-xl font-bold">Cryptocurrencies</h2>
          <button
            phx-click="load-cryptos"
            class="text-sm text-info hover:underline flex items-center"
            disabled={false}
          >
            {if(false, do: "Refreshing", else: "Refresh")}
          </button>
        </div>
        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          <CoinCard.coin :for={coin <- @coins} coin={coin} />
        </div>
      </div>
    </div>
  </div>

  {live_render(@socket, CoinBetWeb.BetslipLive, id: "betslip", sticky: true)}
</Layouts.app>

Instead of going with a stateless or live component, I made the betslip its own LiveView. The betslip indeed shares a lot with the crypto live view such as cryptos, wallet balance etc. and should function perfectly as a component. But I wanted to explore this alternative approach. It also has the benefit that it can persist across page navigation.

At a high level, the CoinCard component looks like this. It’s made up of several smaller private function components.

def coin(assigns) do    ~H"""
  <.live_component module={__MODULE__} coin={@coin} id={@coin.id} />
  """
end

def render(assigns) do
  ~H"""
  <div class="card bg-white border shadow-sm hover:shadow-md transition-shadow p-4 cursor-pointer border-base-300">
    <.coin_header
      image={@coin.image}
      name={@coin.name}
      symbol={@coin.symbol}
      price_change={@coin.price_change}
    />
    <.coin_price_info current_price={@coin.current_price} total_volume={@coin.total_volume} />
    <.coin_bets_count
      :if={@coin.bets.total_longs + @coin.bets.total_shorts > 0}
      total_longs={@coin.bets.total_longs}
      total_shorts={@coin.bets.total_shorts}
    />

    <div class="flex flex-col gap-3 mt-3">
      <div class="flex items-center justify-between">
        <p class="text-sm font-medium text-muted-foreground">Quick Prediction</p>
        <.button
          size="sm"
          phx-click={toggle_price_range(@coin.id)}
          variant="outline"
          class={["h-8 px-2 border-crypto-blue text-crypto-blue hover:bg-crypto-blue/10"]}
        >
          <.icon name="lucide-sliders-horizontal" class="h-4 w-4 mr-1" />
          <span class="text-xs">Custom Target</span>
        </.button>
      </div>

      <.custom_prediction coin={@coin} />

      <div class="flex items-center justify-center gap-1" id={"quick-prediction-#{@coin.id}"}>
        <div class="flex justify-stretch w-full">
          <.prediction_button id={@coin.id} direction="up" text="Will Rise" />
          <.prediction_button id={@coin.id} direction="down" text="Will Fall" />
        </div>
      </div>
    </div>
  </div>
  """
end

Client Side Actions

Up until this point, most of the logic has been handled comfortably on the server side — thanks to LiveView’s process model. (A LiveView is just an Elixir process, after all). But not everything makes sense as a round trip to the server. Some things just feel better staying on the client. There are two places where this tradeoff shows up clearly.

Example 1: the prediction buttons. When a user clicks “Will Rise”, we want to highlight the button in green. If they click “Will Fall” instead, we set the background to red and reset the “Will Rise” button. There’s a good argument to handle this on the server — after all, the betslip still needs to be updated. But for a pure visual effect like this, reaching out to the server can feel sluggish.

Example 2: the custom target slider. This one is clearly a client-only concern. As the user drags the range slider up or down, we want to instantly update the coin’s target price and rate of change. Doing this through the server would be kind of silly so in this case, I used a Phoenix hook to enable custom JavaScript actions. The code itself is rough but simple. We’ll see if it holds up or if it needs some love later on.

Hooks.PriceSlider = {    mounted() {
    const { coinId, coinPrice } = this.el.dataset;

    const targetField = this.el.querySelector(`#price-change-target-${coinId}`);
    targetField.innerText = formatCurrency(coinPrice, "USD");

    const rateField = this.el.querySelector(`#price-change-rate-${coinId}`);

    const rangeField = this.el.querySelector('input[type="range"]');
    rangeField.addEventListener("input", (e) => {
      const targetPrice = e.target.value;
      targetField.innerText = formatCurrency(targetPrice, "USD");
      const rateChange = ((targetPrice - coinPrice) / coinPrice) * 100;
      rateField.innerText = `${rateChange > 0 ? "+" : ""}${rateChange.toFixed(2)}%`;
    });
  },
};

One downside to this approach is that the formatCurrency function is now duplicated, since we also need to format the currency in the LiveView during initial render, and during price updates. This is the only case so far, so it doesn’t bother me much. We’ll see how annoying it gets as we add more features.

Deploying

I won’t go too deep into this since everyone has their own way of shipping apps — especially using Docker. Personally, I use Hetzner for hosting and have a decent setup for server hardening, networking, and firewalls across most of my projects.

For deployment, I reach for Kamal, a lightweight tool from the Rails team that wraps SSH + Docker and keeps things simple. If you haven’t used it before, give it a shot.

Secrets are stored in 1Password, which works really nicely with Kamal. After running mix phx.gen.release --docker, Phoenix generates a Dockerfile and all the necessary release configs.

From there, I run kamal init to generate a config/deploy.yml file and a .kamal/secrets directory. I pull my secrets from 1Password into the environment, and Kamal handles the rest.

Here’s a simplified version of my deploy config:

<% require "dotenv"; Dotenv.load(".env.prod") %>  
service: coinbet
image: ***/coinbet
servers:
  web:
    - <%= ENV["HOST_IP"] %>
proxy:
  hosts:
    - <%= ENV["PHX_HOST"] %>
  app_port: <%= ENV["APP_PORT"] %>

registry:
  server: ghcr.io
  username: <%= ENV["DOCKER_REGISTRY_USERNAME"] %>
  password:
    - GHCR_KEY
builder:
  arch: amd64
  remote: ssh://**@<%= ENV["HOST_IP"] %>
env:
  clear:
    MIX_ENV: prod
  secret:
    - SECRET_KEY_BASE
    - APP_PORT
    - PHX_HOST
    - DATABASE_URL

Once that’s done, I run kamal deploy — and a few minutes later, CoinBet is live.

What’s Next?

In the next post, I’ll cover how I wired up price feeds. Later in the series, I’ll walk through how bets are placed and kept snappy with PubSub and LiveView hooks. I’ll also touch on testing, observability, and everything else that makes it production-ready. Stay tuned.