Outcome bet system

The hub GraphQL endpoint has a built-in makeOutcomeBet method that can be used to implement many games without any custom database schema or plugin logic.

  1. Examples
    1. Coin flip
    2. Wheel of multipliers
    3. Plinko
  2. GraphQL mutation
  3. Hub server config
    1. profit: number
    2. weight: number
    3. houseEdge: number
    4. saveOutcomes: boolean
  4. Validation
  5. Metadata
  6. Provably fair
  7. Best practices
    1. Design outcomes around the UI

Examples

Here are example outcomes for some common games.

Coin flip

A coin flip has two equally likely outcomes.

To make it an acceptable deal for the hub server (house), we introduce a 1% house edge by only giving the player a 0.98 profit on win.

[
  { weight: 1, profit: 0.98 }, // 1% house edge (1 - HE * 2)
  { weight: 1, profit: -1 },
]

Wheel of multipliers

Consider a betting game that spins a wheel of multipliers.

Each segment of the wheel could have an equal chance of being selected, and every other segment results in the player losing their wager.

Here’s a wheel with 10 segments:

[
  { weight: 1, profit: -1 }, // 0x
  { weight: 1, profit: 0.9 }, // 1.9x
  { weight: 1, profit: -1 }, // 0x
  { weight: 1, profit: 0.5 }, // 1.5x
  { weight: 1, profit: -1 }, // 0x
  { weight: 1, profit: 1 }, // 2x
  { weight: 1, profit: -1 }, // 0x
  { weight: 1, profit: 0.5 }, // 1.5x
  { weight: 1, profit: -1 }, // 0x
  { weight: 1, profit: 2 }, // 3x
]

This is equivalent to:

[
  { weight: 5, profit: -1 }, // 0x (five segments)
  { weight: 1, profit: 0.9 }, // 1.9x (one segment)
  { weight: 2, profit: 0.5 }, // 1.5x (two segments)
  { weight: 1, profit: 1 }, // 2x (one segment)
  { weight: 1, profit: 2 }, // 3x (one segment)
]

Plinko

Plinko weights come from Pascal’s triangle.

  • For a plinko board with n rows of pegs, the total number of paths is 2^n.
  • For k moves to the right (and n-k moves to the left), the number of paths is: C(n,k) = n! / (k! × (n-k)!)

In this example, we’ll use 8 rows which means the total number of paths is 2^8 = 256.

For this 8-row Plinko:

  • Outcome 0 (leftmost): C(8,0) = 1 path (LLLLLLLL)
  • Outcome 1: C(8,1) = 8 paths (7L, 1R in any order)
  • Outcome 2: C(8,2) = 28 paths (6L, 2R in any order)
  • Outcome 3: C(8,3) = 56 paths (5L, 3R in any order)
  • Outcome 4 (center): C(8,4) = 70 paths (4L, 4R in any order)
  • Outcome 5: C(8,5) = 56 paths (3L, 5R in any order)
  • Outcome 6: C(8,6) = 28 paths (2L, 6R in any order)
  • Outcome 7: C(8,7) = 8 paths (1L, 7R in any order)
  • Outcome 8 (rightmost): C(8,8) = 1 path (RRRRRRRR)
[
  { weight: 1, profit: 12 }, // 13x (leftmost)
  { weight: 8, profit: 2 }, // 3x
  { weight: 28, profit: 0.3 }, // 1.3x
  { weight: 56, profit: -0.3 }, // 0.7x
  { weight: 70, profit: -0.6 }, // 0.4x (center)
  { weight: 56, profit: -0.3 }, // 0.7x
  { weight: 28, profit: 0.3 }, // 1.3x
  { weight: 8, profit: 2 }, // 3x
  { weight: 1, profit: 12 }, // 13x (rightmost)
]

All the weights add up to 256, thus the probability of each outcome is weight / 256.

GraphQL mutation

Here’s the full GraphQL mutation you’ll likely write:

mutation MakeCoinflipBet($input: HubMakeOutcomeBetInput!) {
  hubMakeOutcomeBet(input: $input) {
    result {
      ... on HubMakeOutcomeBetSuccess {
        __typename
        bet {
          id
          wager
          profit
          metadata
          hubCurrencyByCurrencyKeyAndCasinoId {
            key
            displayUnitScale
            displayUnitName
          }
          outcomes {
            weight
            profit
          }
        }
      }
      ... on HubBadHashChainError {
        message
      }
    }
  }
}

And here are the variables you might send with it for a coin flip game:

const houseEdge = 0.01;

const win: HubOutcomeInput = { weight: 1, profit: 1 - houseEdge * 2 };
const lose: HubOutcomeInput = { weight: 1, profit: -1 };

const input: HubMakeOutcomeBetInput = {
  kind: "COINFLIP",
  outcomes: [win, lose],
  currency: "HOUSE",
  hashChainId: "...",
  wager: 100,
};

const result = await client.mutate({
  mutation: MAKE_COINFLIP_BET,
  variables: { input },
});

Hub server config

For every game you want to offer over the outcome bet system, you need to configure your hub server to allow it by passing options into the MakeOutcomeBetPlugin plugin.

Here’s a full example of how we can configure the hub server for a coin flip game.

import {
  defaultPlugins,
  MakeOutcomeBetPlugin,
  startAndListen,
  type ServerOptions,
  type OutcomeBetConfigMap,
} from "@moneypot/hub";
import { join } from "node:path";

type BetKind = "COINFLIP"; // | "GAME_2" | "GAME_3" | ... (extend as needed)

const betConfigs: OutcomeBetConfigMap<BetKind> = {
  COINFLIP: {
    houseEdge: 0.01, // 1% house edge
    saveOutcomes: true,
  },
};

const options: ServerOptions = {
  plugins: [
    ...defaultPlugins,

    // Pass our config into the plugin
    MakeOutcomeBetPlugin<BetKind>({
      betConfigs,
    }),
  ],
  extraPgSchemas: ["app"],
  exportSchemaSDLPath: join(import.meta.dirname, "schema.graphql"),
  userDatabaseMigrationsPath: join(import.meta.dirname, "migrations"),
};

startAndListen(options).then(({ port }) => {
  console.log(`controller listening on ${port}`);
});

profit: number

The profit is the amount the player wins or loses determined by profit * wager.

Here are examples of a player wagering 100:

  • If they should lose all 100 on a loss, then the profit would be -1 (-1 * 100 = -100).
  • If they should lose 50 on a loss, then the profit would be -0.5 (-0.5 * 100 = -50).
  • If they should win 50 on a win, then the profit would be 0.5 (0.5 * 100 = 50).
  • If they should win 100 on a win, then the profit would be 1 (1 * 100 = 100).
  • If they should win 200 on a win, then the profit would be 2 (2 * 100 = 200).

If you’re used to thinking in terms of multipliers, then you generally subtract 1 from the multiplier to get the profit.

  • A 2x multiplier is 2 - 1 = 1 profit
  • A 0.5x multiplier is 0.5 - 1 = -0.5 profit
  • A 0x multiplier is 0 - 1 = -1 profit

Note that a profit lower than -1.0 means that the player loses more than their wager.

This is rarely what you want, and profits < -1.0 are rejected by the server unless you set the allowLossBeyondWager: true option.

weight: number

The weight is the denormalized probability of the outcome.

Instead of having to specify probabilities that sum of to 1.0, the hub server will do this for you. This makes it easier to write outcomes.

In other words, the probability of each outcome is totalWeight / outcome.weight.

[
  { "weight": 49.5, "profit": 1 }, // Heads: player wins their wager 49.5% of the time
  { "weight": 49.5, "profit": 1 }, // Tails: player wins their wager 49.5% of the time
  { "weight": 1, "profit": -1 } // Edge: player loses their wager 1% of the time
]

houseEdge: number

The house edge determines the minimum expected value for the hub server (house) to accept the bet.

For example, these outcomes have an expected value of 0 for the house since it’s a perfectly fair game:

[
  { "weight": 1, "profit": 1 }, // player wins their wager
  { "weight": 1, "profit": -1 } // player loses their wager
]

And these outcomes have an expected value of -1.0 for the house since the player always wins:

[
  { "weight": 1, "profit": 1 }, // player wins
  { "weight": 1, "profit": 1 } // player wins
]

By setting the houseEdge to a positive number like 0.01 (1%), then the hub server will only accept bets where it has an EV of 1% or more.

We’d have to tweak the outcomes in the house’s favor for the hub server to accept the bet:

[
  { "weight": 1, "profit": 0.98 }, // player wins 98% of their wager
  { "weight": 1, "profit": -1 }
]

saveOutcomes: boolean

By default, the input list of outcomes is not saved in the database.

If you set saveOutcomes: true, then the input list of outcomes will be saved in the database.

This is useful if you want to retrieve the outcomes again when you query for the bet.

const betConfigs: OutcomeBetConfigMap<BetKind> = {
  COINFLIP: {
    houseEdge: 0.01, // 1% house edge
    saveOutcomes: true,
  },
};

For example, maybe a “heads” bet is when the player bets on outcomes [win, lose] and a “tails” bet is when the player bets on outcomes [lose, win]. By saving the outcomes to the database, then the frontend can query the outcomes and determine whether the player bet on heads or tails.

Validation

Going back to our coin flip example, we want to prevent the user from making bets that we don’t expect.

The hub server’s makeOutcomeBet method already does various validations of its own (wager must be positive, currency must exist, etc.).

But we can add our own validation logic using the initializeMetadataFromUntrustedUserInput option:

const betConfigs: OutcomeBetConfigMap<BetKind> = {
  COINFLIP: {
    houseEdge: 0.01, // 1% house edge
    saveOutcomes: true,
    initializeMetadataFromUntrustedUserInput: (input) => {
      // There must be only two outcomes
      if (input.outcomes.length !== 2) {
        return { ok: false, error: "Must provide two outcomes" };
      }

      // Both of the outcomes must have a weight of 1
      if (!input.outcomes.every((o) => o.weight === 1)) {
        return { ok: false, error: "Outcomes must have a weight of 1" };
      }

      // The profits must be -1.0 and 0.98 in any order
      const profits = input.outcomes.map((o) => o.profit).sort();
      if (profits[0] !== -1 || profits[1] !== 0.98) {
        return { ok: false, error: "Invalid profits" };
      }

      return { ok: true, value: {} };
    },
  },
};

Metadata

The hub.outcome_bet table has a JSON metadata column that lets you store additional information about each bet that you can query on the frontend.

You can optionally use the initializeMetadataFromUntrustedUserInput option to initialize the metadata from the user input.

But you must provide the finalizeMetadata(initializedMetadata, betData) option to return the final metadata that will be saved to the database.

For example, let’s extend our coin flip game to store this in the database:

{
  "targetSide": "HEADS" | "TAILS", // The side the player bet on
  "actualSide": "HEADS" | "TAILS" // The side the coin actually landed on
}
const betConfigs: OutcomeBetConfigMap<BetKind> = {
  COINFLIP: {
    houseEdge: 0.01,
    // We don't need to save the outcomes if we're storing metadata about
    // target side vs actual side.
    saveOutcomes: false,
    initializeMetadataFromUntrustedUserInput: (input) => {
      // ...
    },
    finalizeMetadata: (initializedMetadata, betData) => {
      // Heads bet: [0.98, -1]
      // Tails bet: [-1, 0.98]
      const targetSide = betData.outcomes[0].profit === -1 ? "TAILS" : "HEADS";

      // betData.outcomeIdx is the index of the actual outcome that landed
      let actualSide: "HEADS" | "TAILS";
      if (targetSide === "HEADS") {
        actualSide = betData.outcomeIdx === 0 ? "HEADS" : "TAILS";
      } else {
        actualSide = betData.outcomeIdx === 1 ? "TAILS" : "HEADS";
      }

      return {
        targetSide,
        actualSide,
      };
    },
  },
};

Notes:

  • When you use TypeScript, you will be able to see the type of the arguments to these options.
  • betData holds data about the result of the bet. It lets you access information like which outcome index was chosen.
  • The successful outcome of initializeMetadataFromUntrustedUserInput, if provided, is passed to finalizeMetadata as the first argument.

    So if initializeMetadataFromUntrustedUserInput returns { ok: true, value: { foo: "bar" } }, then finalizeMetadata will be called with { foo: "bar" } as the first argument.

  • Now that we store metadata targetSide and actualSide, we don’t need to save the outcomes to the database; the frontend can use the metadata instead of the outcome list to represent the bet in the UI.

Provably fair

TODO

Best practices

Design outcomes around the UI

Your list of outcomes should be designed to match the UI and the user expectation.

For example, the user expects a normal coin flip to have equal probability of winning and losing, so to nudge the EV in the house’s favor, you would modify the profit, not the weights.

On the other hand, you could devise a coin flip game where there’s a 1% chance that the coin lands on the edge (player loses).

[
  { "weight": 49.5, "profit": 1 }, // Heads: player wins their wager (49.5%)
  { "weight": 49.5, "profit": 1 }, // Tails: player wins their wager (49.5%)
  { "weight": 1, "profit": -1 } // Edge: player loses their wager (1%)
]

Now we can give the player the probability distribution they expect and the payouts they expect, and the house EV is 0.01 which would satisfy our 1% house edge.