Workstation4 / Blog / game-design
game-design rules simulation reverse-engineering

Replacing placeholder rules with the real game's economy.

Every simulation starts with a few small fictions. When I first got my Game of Life rebuild moving, the economy was a set of polite stand-ins: a flat payday, a single number pretending to be a salary, a tax that took the same bite from everyone. They let me prove the spin-and-resolve loop worked before any of it had to be correct. This pass took the stand-ins out and replaced them with the rules I had decoded from the original 1998 CD-ROM, and I was surprised how much that one change moved the whole game. A placeholder economy and a grounded one turned out to be the difference between a board that holds your attention and one that quietly does not.

Why I shipped fictions on purpose

Placeholder rules are not laziness; they are scaffolding. Before the economy meant anything, I needed to know a player could spin, walk their car forward, land on a tile, and have something happen without crashing. A flat $40,000 payday gave the resolver a shape to fill: take a spin, advance the car, apply a money delta, mark the leaderboard dirty. That fallback still exists for the handful of junction nodes that have no decoded effect yet, and it deliberately routes through the player's real salary so the leaderboard never goes silent:

func payday(p: PlayerState) -> int:
    var base: int = p.salary if p.salary > 0 else 40_000
    add(p, base, "payday")
    return base

The risk with scaffolding is forgetting that it is scaffolding. A flat payday never swings a leaderboard, so the early games were quietly a bit dull in a way that is easy to overlook when you are still chasing runtime errors. The fix was not inventing balance numbers; it was finishing the decode and letting the real game's data drive the math.

What the real economy actually does

The board itself lives in a decoded data file, board_tiles.json, holding 148 entries. Each tile carries a typed effect (PAY_DAY, TAXES_DUE, HAZARD_PAY, STOCK_GAIN, CAREER_CHANGE, RETIREMENT, and so on), an amount, an insurance_type, an optional career_routing, and a human-readable effect_text. Swapping the placeholders meant teaching the resolver to honor those fields instead of ignoring them. A few of the mechanics that came back to life:

  • Salary curves, not a single number. Nine careers span $20k (Artist) to $100k (Accountant). Careers are no longer interchangeable.
  • A real tax bracket table. TAXES_DUE tiles store their amount as the literal string "tax_table[salary]", looked up per career from a 12.5% to 25% progressive curve. An Artist owes $5k; an Accountant owes $25k. The flat percentage is gone.
  • Routed cash tiles. Some tiles pay the lander's money out to a specific career cohort. The classic example: the Taxes Due tile routes to Accountants, so every accountant on the board collects a share at once.
  • Per-child paydays. Every PAY_DAY adds $5,000 per child on top of salary, so family decisions compound into income later.
  • Insurance that gates hazards, stocks that pay on any roll, and retirement that pays out by tier.

The routed cash tiles were the nice surprise. When a Taxes Due tile lands and pays every accountant in the game at the same instant, the leaderboard moves in a way a flat payday simply cannot. Watching a stream, that swing is the moment people lean in. A flat economy has no moments like that; a real one is mostly moments.

One resolver, 148 tiles

The temptation with this many rules is to scatter conditionals everywhere: a special case here for stocks, a branch there for insurance, a separate path for retirement. I kept it to a single resolver instead. resolve_landing matches on the tile type, applies the effect through the economy service, and returns a uniform event dictionary the broadcast UI already knows how to render into a card and a leaderboard nudge:

match ttype:
    "PAY_DAY":
        var base: int = p.salary if p.salary > 0 else 40_000
        delta = base + p.children_count * 5_000   # $5k per child
        session.economy.add(p, delta, "payday")
    "TAXES_DUE", "PAY_CASH":
        if t.career_routing != "":
            return _resolve_career_split(p, t, t.career_routing, label)
        # ... plain expense path
    "HAZARD_PAY":
        var insured := (ins_type == "AUTO" and p.has_car_insurance) \
            or (ins_type == "HOUSE" and p.has_home_insurance)
        delta = 0 if insured else _amount_int(amount, -10_000)
    "RETIREMENT":
        delta = life_tiles + house_sale + stocks + retire_bonus

That separation matters more than it looks. The resolver knows how to apply a tax bracket or a routed payment; the data file knows which tiles do what and with which numbers. Tuning the economy becomes editing JSON, not rewriting logic, and the logic stays small enough to hold in your head. It also kept one ugly bug class out of the UI: tile fields like career_routing and event_name are legitimately JSON null most of the time, and a naive str(null) in GDScript leaks the literal <null> into an event card. Every extraction goes through one small _str_or helper so a missing field becomes an empty string instead of a "<NULL> SPACE" title on stream.

The routed tile, in detail

Career routing is the rule I most enjoyed getting right, because the original has a subtlety that a flat tax erases entirely. When you land on the Taxes Due tile, three things can happen depending on who you are:

  • If you are an Accountant, the tile is a no-op: you pay nothing. You collect that tax for a living.
  • If accountants exist and you are not one, you pay the bracketed amount and it splits evenly among every active accountant.
  • If there are no accountants in the game at all, your payment goes to the bank and simply leaves the economy.

The even split uses integer division on purpose, and the remainder stays with the bank so the arithmetic is fully deterministic across a replay. That determinism is not a detail; it is the whole verification strategy. The simulation is headless and seeded, so a placeholder run and a real-rules run start from the same seed and the same sequence of spins. Only the money math differs, which let me audit the entire economy swap by diffing two replays rather than taking the change on faith.

Grounding the simulation in the real rules is what makes a watched game feel fair and legible instead of arbitrary.

Retirement, where it all settles

Retirement is where the whole economy gets weighed. Across the game, players quietly accumulate LIFE tiles whose values stay hidden until the end; I draw each from a weighted deck spanning $20k to $250k, skewed low so a typical six-tile retirement cashes out around $480k. At the retirement tile the payout sums four parts: the revealed LIFE-tile total, the house sold back at its purchase price, stock certificates at $50k each, and a destination bonus decided purely by net worth. Cross $1,000,000 and you reach Millionaire Estates for a $1M jackpot; fall short and you take Countryside Acres for $200k. No menu, no chat vote, just the rule. The gate being a hard number is what makes the late game tense: a single routed payday or a lucky LIFE tile can be the difference between the two endings.

Why grounded rules help with fairness

This is a broadcast game; the audience plays by typing into chat, and the one thing I owe them is that the game treats everyone by rules they can see. A flat tax is technically fair in that it hits everyone identically, but it is hard to read: nothing about it rewards a choice, so nothing about it feels earned. The decoded economy is fair in a more useful sense. A player who chose the higher-paying career genuinely owes more tax, and that is visible. A player who bought insurance walks away from a hazard tile untouched, and that is visible too. When the leaderboard swings, chat can usually tell why, and "I can tell why" is most of what fairness feels like to a spectator.

What I took from it

  • Placeholder rules are scaffolding: useful for proving the loop, and easy to leave in too long if you are not careful.
  • Keep resolution in one match-based resolver and push the variety into a data file; with 148 tiles, tuning should be editing data, not rewriting branches.
  • Normalize JSON null at the boundary once, so missing fields never leak into the UI as the string "<null>".
  • A deterministic, seeded simulation lets you swap an entire economy and verify it by replaying the same seed and diffing the results.
  • Legibility is worth treating as a design goal: a rule that rewards a visible choice almost always feels better than a rule that is merely uniform.
W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →