We Built a Multiplayer Board Game on a Subscription Platform. Part 1: The Engine That Can Prove Its Own Dice.

A dice-market board game runs as a plugin on a self-hosted SaaS SDK. Part one is the architecture: a rules core kept pure and enforced by an AST oracle that bans framework imports, the wall clock and the global RNG; dice that are a pure function of (seed, cursor); expected-value move pricing with ROUND_HALF_UP money maths; fee distribution as swappable strategy objects; and spec-hash-validated replay that re-derives any match byte-for-byte.

A recurring claim is that general-purpose app frameworks can’t host anything as stateful as a real-time multiplayer game. It’s a reasonable prior. We tested it by building one — and the interesting part is the engine that can prove its own dice were fair.

A recurring claim in platform engineering is that general-purpose application frameworks — the kind built for subscriptions, commerce and content — can’t host anything as stateful and interactive as a real-time multiplayer game. It’s a reasonable prior. We decided to test it by building one.

BizDevVibes is a dice-market board game in the genre of classic property games, with one rule changed that changes everything. It runs as a plugin on VBWD, a self-hosted full-stack SDK — backend, admin backoffice and player UI, on the same core that serves the shop and the CMS. This is a technical write-up of how it’s put together, and the parts worth stealing whether or not you ever touch this platform.

Part one is the architecture: how the plugin is laid out, why the rules engine is kept surgically pure, and how that purity buys auditable prices and free replay. Part two covers the board builder, LLM opponents and monetisation.

The BizDevVibes board in the VBWD user app: a 40-square loop whose colour groups are funnel stages, four seats each holding 15000 credits, a Roll the dice panel, and a game chat sidebar.
The running game inside the VBWD user app. The board is funnel-40: 40 squares whose colour groups are pipeline stages — Discovery Call, Needs Analysis and Budget Check form one set; Onboarding, Integration and Go-Live another. Completing a set means owning a whole stage of the funnel.

The one rule that makes it a market

Before the architecture, the mechanic — because it’s what forces the engineering.

You roll two dice, a and b. You may move a, b, or a+b. The sum is free — that’s fate, and fate never costs anything. Either single die is a priced purchase. And the fee you pay goes to your opponents, never to the bank.

That last clause is the whole design. In the classic game, fees paid to the bank are a sink: the leader’s lead compounds and the loser’s position decays. Here, buying your way out of fate funds the people you’re beating. The game self-damps.

The same board mid-turn: chat shows the player rolled 6 plus 5 with dice chips 6, 5 and 11; a negotiation window is open; three target squares are highlighted showing 90 cr, 0 cr and FREE.
A live turn. The roll was 6 + 5, so the legal moves are 6, 5 and 11. The engine has priced each one directly on the board: CRM Platform costs 90 cr, Cold Email costs 0 cr, and the sum — Discovery Call — is FREE. The negotiation window is open, so opponents can bid to keep you on the free sum before you choose.

Every one of those prices is computed, not authored. Which means the pricing function has to be trustworthy enough that a player who loses 90 credits believes the number.

Layout: a game is just a plugin

There’s no game-specific machinery in VBWD core. BizDevVibes is three plugins across the three repos, exactly like the shop or the booking system:

vbwd-backend/plugins/bdv/          # rules, API, migrations, tests
  bdv/core/                        # the PURE engine — no framework, no DB
  bdv/models/                      # SQLAlchemy: board.py, match.py
  bdv/services/                    # match_service, seeders, slug
  bdv/routes.py                    # /api/v1/bdv/* and /api/v1/admin/bdv/*
  migrations/versions/             # plugin-owned Alembic revisions
  tests/unit  tests/integration

vbwd-fe-user/plugins/bdv/          # the player UI (Vue 3 + TS)
vbwd-fe-admin/plugins/bdv-admin/   # the board builder

The plugin owns its own tables (bdv_*), its own routes, its own migrations and its own permissions (bdv.play, bdv.boards.*). Core never learns the word “game”. That’s the platform’s central rule — the core stays agnostic, plugins are where domain knowledge lives — and a board game is a good stress test of it, because a game is about as far from “SaaS CRUD” as a domain gets.

What the plugin gets for free from core is most of the boring half of any multiplayer product: user accounts and auth, RBAC and access levels, the admin backoffice shell, a token economy for monetisation, an event bus, and the plugin loader that mounts all of it. The game code is the game.

The pure core, and the test that enforces it

Here’s the decision everything else rests on. Everything in bdv/core/ — the dice, the options, the pricing, the fee policies, the economy, the state machine — is pure Python. No Flask. No SQLAlchemy. No clock. No global random.

That isn’t style policing, and we don’t rely on discipline to maintain it. There’s an oracle test that walks the AST of every module in core/ and fails the build on violation:

BANNED_IMPORT_ROOTS = {
    "vbwd", "flask", "sqlalchemy", "alembic", "redis",
    "requests", "httpx", "openai", "anthropic", "plugins",
}

BANNED_CALLS = {
    ("random", "random"), ("random", "randint"), ("random", "choice"),
    ("random", "shuffle"), ("random", "seed"),
    ("time", "time"), ("datetime", "now"), ("datetime", "utcnow"),
    ("uuid", "uuid4"),
}

The subtlety is in what’s allowed. dice.py may construct random.Random(seed) — a seeded generator is reproducible. What’s banned is drawing from the global RNG or reading the wall clock, because no replay could ever reproduce those.

The strongest assertion in that file is the last one: import the engine and play a match with no Flask app, no database, nothing.

def test_engine_is_importable_without_an_app_context():
    spec = BoardSpec(squares=(...))
    state = new_match(spec, MatchConfig(seed="x", seat_count=2))
    assert len(state.seats) == 2

As the test’s own docstring puts it: purity is what makes move prices reproducible and auditable, replay free, and the balance harness runnable at compute cost. If that test goes red, the product claim goes with it.

Deterministic dice

There is no shared RNG anywhere. A roll is a pure function of (seed, cursor):

def roll_at(seed: str, cursor: int) -> Tuple[int, int]:
    """The two dice for a given roll index of a given match."""
    generator = random.Random(f"{seed}:{cursor}")
    return generator.randint(1, 6), generator.randint(1, 6)

Roll 47 of match lucid-gadget-49 is the same two numbers today, next year, and on any machine. Card decks work the same way — shuffled_order(seed, deck, size, reshuffle) gives a draw order derivable from the seed alone.

This is worth doing even if you never build a replay feature, because it turns “the dice felt rigged” from an argument into a computation.

Priced options

The option set is four lines, and the doubles case is the one people miss:

def legal_options(roll):
    """Deduped, ascending. Doubles collapse to two options: {3,3} -> (3, 6)."""
    first, second = roll
    return tuple(sorted({first, second, first + second}))

Pricing is expected-value based, and the entire specification fits in the module docstring:

options(roll{a,b}) = dedup([a, b, a+b])
ev(option)         = immediate value delta of landing on the resulting square
price(option)      = max(0, round(k_price x (ev(option) - ev(sum))))
price(sum)         = 0                      # fate is always free
affordable         = price <= floor(cap_pct x cash)

You pay for advantage over fate. If a single die lands you somewhere no better than the free sum would, the price floors at zero — which is exactly why Cold Email shows 0 cr in the screenshot above while CRM Platform shows 90.

One detail that matters more than it looks: the rounding is explicit ROUND_HALF_UP, not Python’s built-in round(). Python uses banker’s rounding, which would make prices on a 0.5 boundary depend on the parity of the number — surprising behaviour for money, and the kind of thing that erodes trust in a computed price.

Fee distribution as a strategy, not an if-chain

Who receives the move-purchase fee is the game’s main balance lever, so it’s a resolvable policy rather than a branch:

class FeeDistributionPolicy(Protocol):
    policy_id: str
    def distribute(self, amount, state, payer) -> Mapping[int, int]: ...

Three ship today. AllToPoorest sends 100% to the seat with the least cash — the strongest self-damping rule, with ties broken on lowest seat index so the outcome stays deterministic. SplitAmongOpponents divides evenly and gives the remainder to the poorest so totals reconcile exactly. And ToBank exists purely as the classic-game control arm, so the balance harness has something to measure against.

Because it’s resolved by id, an admin can change the economics of a board from a dropdown — which is where part two picks up.

Solvency: bankruptcy is computed, not assumed

The economy module handles selling, borrowing and interest, and makes one design choice worth copying. A seat that cannot pay is not immediately bankrupt. Bankruptcy is what happens when liquidating everything still falls short — a computed fact rather than a first resort.

That turns the worst moment of a match into a decision: sell, and raise cash while permanently shrinking your income; or borrow, and keep the asset earning while adding a cost that recurs every lap.

Interest is charged on passing GO — not on a timer. That hook is deterministic, already exists in the move resolver, and ties the cost of debt to tempo: a player buying bigger moves laps faster and therefore pays interest more often. The dice market and the debt market push against each other, which is precisely the tension you want.

Replay: the audit guarantee

All of the above adds up to one property. Same spec + same seed + same action log ⇒ byte-identical state, forever.

The replay module rejects a mismatch loudly: replaying against a different spec_hash raises, because a match must always be re-derivable under the exact rules it was played under. Change a board’s constants and old matches don’t silently re-derive differently — they refuse.

Practically, this means a dispute is resolvable. A player who thinks a 90-credit quote was wrong can have it re-derived from the log months later, and the answer is arithmetic rather than an appeal to server logs.

What this cost

The honest accounting. The plugin carries 15 unit test modules plus integration tests, and the ones that earn their keep are the boring ones — a purity oracle, a naming denylist, engine regressions, a fee-reconciliation test proving distributions sum exactly to the amount paid.

Keeping the engine pure is a real constraint. You cannot reach for the database in the middle of resolving a move; you assemble the state first, resolve, then persist. That’s more upfront structure than the obvious approach. It’s also why the engine can be exercised thousands of times per second in a balance harness with no infrastructure at all — which is the only practical way to tune an economy with this many interacting dials.

And the platform genuinely carried the rest. Auth, accounts, permissions, the admin shell, the token economy and the plugin loader were not written for this game.

Next

Part two gets to the parts you can see: the admin board builder where the economy constants are form fields, LLM-driven opponents seated at the table, the real-time player UI, and how a match gets paid for. Meanwhile the platform itself is at vbwd.cc — see the plugin catalogue, the architecture, and the developer docs.

Learn more about VBWD

VBWD is a self-hosted, source-available platform for building subscription products, marketplaces, and AI-powered apps. Explore it further:

VBWD is source-available — get the SDK on GitHub.

Happy
Happy
0%
Sad
Sad
0%
Excited
Excited
0%
Angry
Angry
0%
Surprise
Surprise
0%
Sleepy
Sleepy
0%

Coinbase Will Now Take USDC From AI Agents. On Its Own Docs, Machines Already Outnumber Humans.

We Built a Board Game on a SaaS Platform. Part 2: The Economy Is a Form and the Opponents Are LLMs.

Leave a Reply

Your email address will not be published. Required fields are marked *

2 × 2 =