Production-grade observability for a single-player drug economy simulator.
ScheduleObserved is a MelonLoader mod for Schedule 1 that exposes the in-game economy as Prometheus metrics and Loki log streams, with a prebuilt Grafana dashboard. Player finances, customer relationships, product flow, dealer balances, and per-deal geolocation events — all queryable, all graphable, all labeled by save slot.
Built for fun, partially as an excuse to practice observability tooling on a non-trivial data source, and partially because when you install SRE reflexes on a video game you get charts like dealer cash-on-hand with a green-to-red gradient and you cannot help yourself.
All per-customer / per-dealer / per-product metrics are labeled with save_slot so you can filter by game instance.
| Metric | Type | Labels | Description |
|---|---|---|---|
s1_player_cash_balance |
gauge | save_slot | Player cash on hand |
s1_player_online_balance |
gauge | save_slot | Player ATM balance |
s1_player_rank |
gauge | save_slot, rank | Player rank (enum name in label) |
s1_player_xp |
gauge | save_slot | XP within the current tier |
s1_player_total_xp |
gauge | save_slot | Cumulative XP |
s1_player_xp_to_next_tier |
gauge | save_slot | XP threshold for next tier |
s1_customer_weekly_budget |
gauge | save_slot, customer | Lerp'd weekly spend |
s1_customer_per_order_cap |
gauge | save_slot, customer | Per-deal price ceiling |
s1_customer_addiction |
gauge | save_slot, customer | 0-1 addiction level |
s1_customer_relationship |
gauge | save_slot, customer | Raw relationship delta |
s1_customer_orders_per_week |
gauge | save_slot, customer | Effective weekly order days |
s1_customer_spend_base_total |
counter | save_slot, customer | Pre-bonus payment accumulation |
s1_customer_spend_total |
counter | save_slot, customer | Post-bonus payment accumulation |
s1_customer_deals_completed_total |
counter | save_slot, customer | Completed deals |
s1_product_sold_total |
counter | save_slot, product_id, drug_type | Units sold |
s1_dealer_cash |
gauge | save_slot, dealer | Per-recruited-dealer cash |
s1_unlocked_customers |
gauge | save_slot | Count of unlocked customers |
s1_mod_info |
gauge | version | Static metadata |
One JSON object per completed deal, tailed by Promtail and pushed to Loki:
{
"ts": "2026-04-20T22:09:12.4321000Z",
"event": "deal",
"save_slot": "Little Buds",
"customer": "Jessi Waters",
"product_id": "girlscoutcookies",
"drug_type": "Marijuana",
"base": 450.00,
"units": 5,
"lat": 0.4231,
"lon": 1.1094
}lat / lon come from the customer's world-space position at handover, scaled by 1/1000 so they land in the valid WGS-84 degree range. They are not real earth coordinates. The dashboard's Geomap panel will display them in the Atlantic Ocean off the coast of Ghana, or you can layer a custom tile server with the actual Schedule 1 city map underneath.
Counter state is serialized as TSV into <savefolder>/ScheduleObserved/counters.tsv on every game save. Loaded on every save load. Counters are scoped by save slot so switching games doesn't pollute totals.
Customer.ProcessHandover fires for every customer handover in the game world, not just the player's. Rival cartel activity — deals between NPC dealers and their customers — shows up in your metrics alongside your own. Unintentional SIGINT as entertainment.
- Install MelonLoader 0.6+ on Schedule 1. Works on either the Mono (
alternateSteam beta) or IL2CPP (default) branch. - Grab
ScheduleObserved.dllfrom Releases (or build yourself — see below). - Drop it into
Schedule I/Mods/. - Launch the game. The
[ScheduleObserved]section appears inUserData/MelonPreferences.cfg; the metrics endpoint starts onhttp://localhost:9184/metrics.
cd monitoring
cp .env.example .env
# edit .env: set GAME_DIR to your Schedule 1 install
docker-compose up -d| Service | URL | Notes |
|---|---|---|
| Prometheus | http://localhost:9090 | scrape target localhost:9184 |
| Loki | http://localhost:3100 | log ingest |
| Grafana | http://localhost:3000 | admin / scheduleobserved |
The dashboard "ScheduleObserved — Schedule 1 Economy" auto-provisions. Nine panels covering cash, XP, customers, products, dealers, and the deal-location Geomap.
All services run in host networking mode to dodge host.docker.internal resolution issues on older Docker Compose versions. This means ports 9090/3000/3100/9184 must be free on the host.
Requires .NET 6 SDK. The project auto-detects which branch of the game is installed:
- Mono branch →
net472DLL, referencesSchedule I_Data/Managed/*.dll. - IL2CPP branch →
net6.0DLL, referencesMelonLoader/Il2CppAssemblies/*.dll. Requires launching the game at least once so MelonLoader generates interop proxies.
dotnet build -c ReleaseOutput: bin/Release/<tfm>/ScheduleObserved.dll, auto-deployed to <GameDir>/Mods/. Override the game path for non-standard installs:
dotnet build -c Release -p:GameDir="/other/path/Schedule I"dev.sh wraps the common flow (build + stack + play) — see ./dev.sh help.
- Metrics exporter uses a raw
TcpListener+ manual HTTP framing.HttpListenerunder Wine (Proton-hosted Unity games) accepts connections but silently fails to dispatch requests;TcpListeneris a thin BSD socket wrapper and works correctly. - Prometheus exposition format is
\n-delimited. Wine'sEnvironment.NewLineis\r\n, which Prometheus rejects withinvalid metric type "gauge\r". Body is normalized once before serving. - Cross-branch source uses
#if Monoto switch betweenScheduleOne.*andIl2CppScheduleOne.*namespaces, betweenSystem.Collections.GenericandIl2CppSystem.Collections.Generic, and between direct property access and IL2CPP's synthesizedsync___get_value_*accessors for FishNet-synced fields. - Gauge snapshots happen on Unity's main thread (
OnUpdate, throttled byMetricsRefreshInterval). The HTTP accept loop runs on a background thread and serves an immutable string reference, so no locking is needed.
MIT. See LICENSE.
Built with Claude Code as a pair-programming partner and super-duper friendly buddy.