礦挖得越快,遊戲越快失衡:把遊戲經濟當成動態系統

挖礦賺來的錢可以買鑽頭,鑽頭又能挖更多礦,玩家的產能就會像複利一樣成長。每個價格單獨看都合理,連在一起卻形成正回饋,跑出設計時沒預料到的曲線。要分析這種問題,可以把遊戲經濟寫成動態系統,分清存量和流量,用「下一刻等於現在加上流入減去流出」描述變化,檢查回饋、延遲和參數敏感度。

借用這些方法時,得記得遊戲經濟不遵守物理守恆,規則可以憑空創造或消滅資源。玩家也會看情況改策略,不能當成粒子處理,所以模擬只能測試假設,不能證明真實玩家會照模型行動。

存量、流量與差分方程

《Game Mechanics: Advanced Game Design》第 4 章把內部經濟拆成資源、實體,以及四種操作資源的機制:來源(source)、消耗源(drain)、轉換器(converter)、交易器(trader)。分析時還要分清存量與流量:

  • 存量是某個時間點有多少,例如倉庫裡的礦石、船上的鑽頭數。
  • 流量是每單位時間多少,例如每小時挖 10 單位、每小時維護費 2 單位。

存量只會因為流量改變,寫成差分方程就是:

stock[t+1] = stock[t] + production[t] - upkeep[t] - spending[t]

production 取決於鑽頭數,鑽頭數取決於過去花了多少在 spending,迴路就出現了。設定是:每支鑽頭每小時挖 10 單位,一支鑽頭 1,000 單位;只要買得起就再買,就是把收入全部再投資。連續近似下,鑽頭數每小時成長 10 / 1000 = 1%,大約每 ln 2 / 0.01 ≈ 69 小時翻倍。

正回饋、負回饋與延遲

範例延續前面的太空採礦遊戲,各組都跑 500 小時,逐步加上維護費、遞增的價格和建造時間。程式已在 Python 3.13.16 上執行:

import math
import random
import sys

# One tick = one in-game hour. A drill digs ORE_PER_DRILL ore per tick.
ORE_PER_DRILL = 10.0
DRILL_PRICE = 1_000.0          # in ore
UPKEEP = 2.0                   # ore per drill per tick


def run(ticks, reinvest, upkeep=0.0, price_growth=1.0, build_delay=0, eff=1.0):
    """stock[t+1] = stock[t] + production - upkeep - spending"""
    stock, drills, price, queue = 0.0, 1, DRILL_PRICE, []
    for t in range(ticks):
        drills += sum(1 for ready in queue if ready == t)
        queue = [r for r in queue if r > t]
        stock += drills * ORE_PER_DRILL * eff - drills * upkeep
        while reinvest and stock >= price:
            stock -= price
            if build_delay:
                queue.append(t + build_delay)
            else:
                drills += 1
            price *= price_growth
    return stock, drills + len(queue)


print("== 1. stock and flow over 500 ticks ==")
for label, kw in (("never upgrade", dict(reinvest=False)),
                  ("reinvest everything", dict(reinvest=True)),
                  ("reinvest + upkeep 2/tick", dict(reinvest=True, upkeep=UPKEEP)),
                  ("+ price x1.15 per drill", dict(reinvest=True, upkeep=UPKEEP, price_growth=1.15)),
                  ("+ 48-tick build time", dict(reinvest=True, upkeep=UPKEEP, price_growth=1.15, build_delay=48))):
    s, d = run(500, **kw)
    print(f"{label:26} drills {d:6d}  ore in stock {s:9.0f}")
print(f"growth rate with reinvest: ln2 / (ore per drill / price) ~ doubling every "
      f"{math.log(2) / (ORE_PER_DRILL / DRILL_PRICE):.0f} ticks")
== 1. stock and flow over 500 ticks ==
never upgrade              drills      1  ore in stock      5000
reinvest everything        drills     82  ore in stock       620
reinvest + upkeep 2/tick   drills     30  ore in stock       864
+ price x1.15 per drill    drills      9  ore in stock      2265
+ 48-tick build time       drills      7  ore in stock      2222
growth rate with reinvest: ln2 / (ore per drill / price) ~ doubling every 69 ticks

不升級的玩家 500 小時後有 5,000 單位,資產線性成長;全部再投資的玩家則有 82 支鑽頭,每小時產能是前者的 82 倍。產出持續投入產能,讓之後的產出更多,這個正回饋會隨著時間拉大兩者的差距。

後面三列逐步加上三種常見的限制。維護費是跟存量成正比的流出,每支鑽頭每小時扣 2,淨產能從 10 變成 8,鑽頭數就從 82 降到 30。比例看起來只扣兩成,但扣在成長率上,複利會放大差距。

價格隨購買次數遞增,每多一支鑽頭的報酬也會降低。每支貴 15% 之後只剩 9 支,因為已經買不起下一支,倉庫反而存了比較多礦。成長從指數變成接近對數,這是書裡說的負回饋,會壓低成長。

建造時間則讓投入與產出之間有了延遲:錢已經花出去,鑽頭要 48 小時後才開始挖,500 小時下少了 2 支。延遲不一定讓系統慢很多,也可能造成振盪,後面的市場模型會看到這個現象。

參數對達標時間的影響

假設要讓玩家晚一點拿到 50 支鑽頭,可以逐一小幅調整參數,比較達標時間的變化,找出影響較大的項目:

print("\n== 2. sensitivity: ticks until 50 drills ==")
def ticks_to(target, **kw):
    for t in range(1, 20_000):
        if run(t, reinvest=True, **kw)[1] >= target:
            return t
    return None
base = dict(upkeep=UPKEEP, price_growth=1.05)
print(f"baseline                 {ticks_to(50, **base)}")
print(f"mining efficiency +10%   {ticks_to(50, **base, eff=1.10)}")
print(f"mining efficiency -10%   {ticks_to(50, **base, eff=0.90)}")
print(f"upkeep 2 -> 4 per tick   {ticks_to(50, upkeep=4.0, price_growth=1.05)}")
print(f"price growth 1.05 -> 1.07 {ticks_to(50, upkeep=UPKEEP, price_growth=1.07)}")
== 2. sensitivity: ticks until 50 drills ==
baseline                 1153
mining efficiency +10%   1025
mining efficiency -10%   1317
upkeep 2 -> 4 per tick   1537
price growth 1.05 -> 1.07 1781

採礦效率改 10%,達標時間改 11% 到 14%,而且減 10% 的影響比加 10% 大,變化並不對稱。價格成長率只從 5% 調到 7%,時間卻多了 54%,因為它是指數裡的底數,會影響每一支鑽頭,企劃調整這類參數時得特別小心。

這裡的作法是一次只動一個參數。參數之間有交互作用時(例如效率和維護費一起改),要做二維掃描或更系統的方法,這篇不展開。

平衡點與穩定性:有延遲的市場

上面是單一玩家的經濟。有了玩家之間的市場,價格也變成系統裡隨時間變動的量。經濟學的蛛網模型(cobweb model)描述的就是有延遲的市場:生產者要在價格揭曉前決定產量,只能參考上一期的價格。假設礦石的需求是 price = 100 - q,礦工依上一輪的價格決定這一輪挖多少,q = slope · price[t-1]:

print("\n== 3. a market with lag: miners plan output from last round's price (cobweb) ==")
def cobweb(supply_slope, rounds=8, demand_a=100.0, demand_b=1.0):
    """demand: price = a - b*q.  supply: q[t] = slope * price[t-1]."""
    price, out = 60.0, []
    for _ in range(rounds):
        q = supply_slope * price
        price = max(0.0, demand_a - demand_b * q)
        out.append(round(price, 1))
    return out
for slope in (0.5, 1.0, 1.5):
    print(f"supply slope {slope}: prices {cobweb(slope)}")
== 3. a market with lag: miners plan output from last round's price (cobweb) ==
supply slope 0.5: prices [70.0, 65.0, 67.5, 66.2, 66.9, 66.6, 66.7, 66.6]
supply slope 1.0: prices [40.0, 60.0, 40.0, 60.0, 40.0, 60.0, 40.0, 60.0]
supply slope 1.5: prices [10.0, 85.0, 0.0, 100.0, 0.0, 100.0, 0.0, 100.0]

三種情況都有平衡點,偏離後的走勢卻不同。礦工對價格反應溫和(0.5)時,價格來回擺盪的幅度越來越小,回到平衡點 66.7;反應跟需求一樣敏感(1.0)時,價格就在 40 和 60 之間永遠振盪。反應過度(1.5)則會讓振幅越來越大,碰到 0 和 100 的邊界後,就在兩端來回跳。

把式子整理一下,price[t] = 100 - slope · price[t-1],每一輪的偏差會乘上 -slope,絕對值小於 1 才會收回來。Wikipedia 的條目用供需曲線的相對斜率描述同一個條件。放到遊戲裡,如果玩家真的依昨天的價格決定今天挖多少,或價格資訊有延遲(每日更新的行情表),或升級要等很久才生效,系統就更容易擺盪。要讓市場穩定,可以縮短延遲、限制單期產量變化,或讓 NPC 在價格偏離太多時進場收購或拋售。

Monte Carlo:策略、運氣與追趕時間

確定性模型看平均趨勢,玩家實際的處境還要加上運氣和策略。第 8 章用人工玩家(artificial player)跑數百次模擬來比較策略,這裡用同樣的想法:兩千個玩家、30 天、每天四小時,每次鑽探有 1% 機率挖到值 300 的稀有礦(就是〈掉落率 1%,為什麼挖一百次還是沒有〉的那個 1%),比較三種策略:

print("\n== 4. 2,000 players, 30 days of 4 one-hour sessions, random drops ==")
def season(strategy, rng, days=30):
    ore, credits, drills = 0, 0.0, 1
    for _ in range(days * 4):
        for _ in range(drills * 10):             # 10 digs per drill per session
            ore += 1
            if rng.random() < 0.01:              # 1% rare find, worth 300 credits
                credits += 300
        credits += ore * 1.0                     # sell common ore
        ore = 0
        price = 1_000 * 1.15 ** (drills - 1)
        credits -= drills * 2 * 1.0              # upkeep in credits
        if strategy == "reinvest" and credits >= price:
            credits, drills = credits - price, drills + 1
        elif strategy == "keep 2,000 buffer" and credits - 2_000 >= price:
            credits, drills = credits - price, drills + 1
    worth = credits + sum(1_000 * 1.15 ** i for i in range(drills - 1)) * 0.5  # resale at half
    return worth, drills

rng = random.Random(20261007)
for strategy in ("never upgrade", "reinvest", "keep 2,000 buffer"):
    res = sorted(season(strategy, rng) for _ in range(2_000))
    w = [r[0] for r in res]
    d = sorted(r[1] for r in res)
    print(f"{strategy:18} worth P10 {w[200]:8.0f}  median {w[1000]:8.0f}  P90 {w[1800]:8.0f}"
          f"  drills median {d[1000]} (P10 {d[200]}, P90 {d[1800]})")
late = sorted(season("reinvest", rng, days=15)[0] for _ in range(2_000))
print(f"joined on day 15, reinvest: median worth on day 30 {late[1000]:8.0f}")
print(f"\nPython {sys.version.split()[0]}")
== 4. 2,000 players, 30 days of 4 one-hour sessions, random drops ==
never upgrade      worth P10     3360  median     4560  P90     6060  drills median 1 (P10 1, P90 1)
reinvest           worth P10     6867  median    11288  P90    16261  drills median 11 (P10 8, P90 13)
keep 2,000 buffer  worth P10     2948  median     5119  P90     9077  drills median 5 (P10 2, P90 8)
joined on day 15, reinvest: median worth on day 30     2507

這裡把資產算成現金加上鑽頭以半價轉賣的價值,換一種估值方式,數字也會變。在這個假設下,全部再投資的中位數是不升級的 2.5 倍,P10 也比不升級的 P90 高,策略的影響蓋過了運氣。

保留 2,000 緩衝卻讓 P10 比完全不升級還低。緩衝延後了升級,晚買的鑽頭還沒回本,估值又只算半價,再扣掉維護費,資產反而比不升級低。

同一個策略之下,運氣仍讓 P10 和 P90 的鑽頭數差 5 支,資產差兩倍多,前期幾次稀有礦讓第一次升級提早,回饋又放大了後續差距。

第 15 天才加入、用最好的策略玩到第 30 天,中位數資產只有早到玩家的 22%,落後幅度遠大於一半,因為複利持續拉大差距。後來的玩家若要追上,就需要新手加成或對高資產玩家遞增消耗之類的額外機制。

《Game Mechanics》第 8 章在 SimWar 的例子裡也提醒,兩個人工玩家採同樣的策略、結果勢均力敵,不能直接推論遊戲是平衡的。模擬結果的可信度取決於策略設計得多接近真實玩家,而真實玩家會在看到結果之後改變策略。

營運中的經濟紀錄

EVE Online 的營運商 CCP 每月公布 Monthly Economic Report,內容包含 ISK 的來源與消耗(faucets and sinks)、貨幣供給與流通速度、各區域的生產與破壞價值。2024 年 11 月的報告在 12 月 12 日發布,還附了一則更正,說明 faucets 與 sinks 的總和先前算錯了。報告定期核對遊戲的存量與流量,處理的也是本文差分方程描述的變化。

交易機制的公開紀錄還有《Diablo III》的拍賣場。Blizzard 在 2013 年 9 月 17 日宣布關閉金幣與現金拍賣場,2014 年 3 月 18 日關閉。Wikipedia 引述當時的遊戲總監 Jay Wilson 在 2013 年 3 月說拍賣場「really hurt」這款遊戲。拍賣場是一個跟遊戲內掉落並存的交易器,這裡只記錄公開的時間線,不推測它對經濟的具體影響。

「用數學描述遊戲世界」系列寫到這裡結束。前面處理了空間、時間、單次的隨機和有記憶的隨機,〈保底不是把機率調高〉算的是一個玩家抽到為止的成本;這篇則把範圍拉到多次互動累積後,整個系統會怎麼變。

補充筆記

  • 分清存量與流量,寫出 下一刻 = 現在 + 流入 - 流出,比較容易看出數字之間的連結與回饋。收入能再投入產能,就會形成接近指數成長的正回饋;維護費與遞增價格是負回饋,建造時間則帶來延遲。
  • 一次調一個參數,可以看出結果對它有多敏感,價格成長率這類指數底數即使小改,也可能讓結果差很多。市場有延遲時,反應強度會決定它回到平衡、永久振盪或發散,縮短延遲或限制單期變化有助穩定。
  • Monte Carlo 的策略比較要看分位數與追趕時間,本例晚 15 天加入的玩家,中位數資產只有早到者的 22%。遊戲經濟不守恆,玩家也會改策略,這些模擬只能測試假設,結論仍得拿真實資料檢查。

延伸閱讀

  • Adams、Dormans《Game Mechanics: Advanced Game Design》第 4、6、8 章:內部經濟的四種機制、回饋結構與隨機性、人工玩家與模擬平衡,遊戲設計教科書,本文的分析框架主要來自這三章。
  • Wikipedia〈Cobweb model〉:Kaldor 1934 年的形式化、延遲假設與收斂條件,百科條目,原始文獻列在條目的參考資料。
  • CCP Games〈Monthly Economic Report - November 2024〉:EVE Online 官方經濟報告,faucets and sinks 與更正說明,營運商自行發布的資料。
  • Wikipedia〈Diablo III〉:拍賣場宣布關閉與實際關閉的日期、Jay Wilson 的發言,百科條目,原始報導列在條目的參考資料。

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16 實測。