DEV Community

QuietMoose
QuietMoose

Posted on

Monte Carlo game economy stress-testing on a solo budget — and the two-speed architecture that made it feel instant

The problem worth solving

Game economy design happens in spreadsheets. A designer models a gem earn rate, a pull cost, a pity counter — all in isolation. The spreadsheet can't run 1,000 players through 90 days simultaneously. It can't show you that the whale cohort monetizes fine while minnows starve by day 45. It definitely can't show you what happens when someone asks "can we make gems 20% cheaper?" and three interconnected systems cascade red.


The engine

The sim engine is a pure Python + NumPy package called loot_sim. The domain model is a directed graph: sources (faucets), sinks (drains), converters, pools, gates, flow edges, and modifier edges (feedback loops that scale a target node's parameter each tick).

The day loop is fully vectorized. State arrays are float32, shaped (runs, players). A Python-level per-player loop is a bug. Benchmark gate: full sim in under 4 seconds at 2 GB — in practice, 1,000 runs × 1,000 players × 90 days runs well under that on a cold Lambda.

Seeded RNG uses default_rng(SeedSequence(seed)).spawn(runs) — same inputs always return identical bytes. That property is load-bearing for the two-speed architecture.


The two-speed architecture

This is the pattern I'm most likely to reuse. The tension in any simulation UI: real-time feedback requires fast computation, meaningful statistical results require expensive computation. Most tools pick one. I didn't want to.

Two separate engines, two separate speeds, clear labeling so the user always knows which they're looking at:

  • Preview engine — TypeScript, client-side, deterministic mean-flow (no randomness, no cohort splitting — just expected value through the graph). Runs on every keystroke and slider drag. Under 16 ms. Labeled PREVIEW.
  • Full Monte Carlo — fires on Lambda, debounced 800 ms after slider release, immediately on button press, after every NLP mutation. Fan chart bands (p5–p95) replace the preview when the result arrives.

The user never waits for the number that matters — they just get it.

Parity tests enforce the preview median stays within ±10% of the full sim p50 at days 30, 60, and 90. If the preview drifts, the test fails and I fix the preview — I never widen the tolerance.


Single-Lambda constraint

One Lambda: FastAPI + Mangum adapter + metering + Bedrock client + sim engine. One SAM template. One deploy command. Never touched the AWS console for infra changes.

The constraint saved hours every week — no service discovery, no inter-service latency to debug, no IAM roles between components. Tradeoff: a slow sim blocks the process. At under 4 seconds and reserved concurrency 10, that never became a problem.


Cache before Bedrock, always

The NLP bar is the most visible feature and the most expensive. Strict cost order: cache → Bedrock → suggestion chips → client-only. Bedrock never gets called without checking the cache first.

~42 parameterized prompt intents — regex + RapidFuzz fuzzy matching at ≥87 similarity — running in both the frontend bundle and the Lambda. Common queries resolve without a network call. Validated Bedrock results write through to DynamoDB so the cache improves with use. In testing, ~60% of prompts never hit Bedrock.

The kill switch is one DynamoDB write. Set flag#bedrock → enabled: false and all LLM spend stops in under 60 seconds. The product stays up on cached results, suggestion chips, sliders, and the preview engine. Designing for this forced the fallback tiers to actually work.


One mutation format, everywhere

Sliders, NLP results, cached responses, and undo all emit the same ops list: add_node, update_node, remove_node, add_edge, update_edge, remove_edge, update_cohort, clarify. One apply_ops function per runtime applies them — pinned by shared test vectors that run against both the TypeScript and Python implementations and must produce identical graphs.


The pattern worth stealing

Users don't need the real answer instantly — they need something to look at while they wait, and they need it to be honest about what it is.

If you're building anything with a simulation loop behind a real-time UI, the two-speed pattern works: a fast deterministic approximation on every input, a full expensive result on commit, parity tests to keep them honest, and clear labeling throughout so the user always knows which is which.

Top comments (0)