The Two-Week Pulse: Automating Attrition’s Game Health Telemetry
Development period: September 24, 2026. This Field Dispatch documents the automated scheduled pipeline that updates Attrition’s Game Health Dashboard every two weeks, explains the data freshness contract, and connects consented GA4 metrics to a zero-infrastructure static frontend.
Update, September 29, 2026: The first version of this pipeline overwrote a single JSON file on every run, so each sync erased the one before it. That is fine for a scoreboard and useless for research. The pipeline now persists an immutable, dated snapshot per non-overlapping period, and the dashboard lets you switch between any stored period or all of them combined. The rewrite also fixed a dimension-name mismatch (
commandervs the emittedcommander_id) that would have broken the first live query. The sections below describe the current design.
When we published From Simulated Armies to Real Players, we shared the first thirty-eight completed Wars recorded from real human players. It answered our first pressing design questions: Matthias von Greyerz was shockingly vulnerable to human deception (14.3% win rate), Marcel and Bastien were stubbornly resilient, and 43% of un-filtered exploration rows were harmless stream noise.
The immediate question that followed was: Is this dashboard going to update as new games are played, or is it frozen in amber?
Static reports are easy. Live, self-updating dashboards on a serverless GitHub Pages blog are significantly more challenging.
This post documents how we solved that problem: a scheduled GitHub Actions pipeline that pulls fresh metrics from the Google Analytics 4 Data API every two weeks, writes an aggregated public contract, and automatically updates the blog dashboard without requiring any backend servers.
Live Data Freshness Monitor
Telemetry Sync Pipeline: Active
CRON: BI-WEEKLYWhy Every Two Weeks?
In web telemetry, the instinct is often to build real-time streaming dashboards: live tickers updating every five seconds, socket connections, and instant alerts.
For a game like Attrition, real-time streaming is not only unnecessary—it is actively deceptive:
- Statistical Mass over Noise: Attrition contains rare combinatorial events—a Two defeating an Ace in a crucial Battle, a 43-turn marathon war, or a 3-layer sacrifice cascade. Over an hour or a single day, the sample size is tiny. A single player trying a radical strategy can make a commander look invincible or broken. A 14-day cadence gives each period statistical mass, and because periods never overlap they stack into a growing history that can be pooled whenever a question needs more Wars.
- Player Privacy & Aggregation Thresholds: In our telemetry design, we committed to public reporting only in aggregate ($N \ge 30$). Real-time streaming risks letting an observer correlate a friend’s play session with a public spike. A two-week aggregation protects player anonymity while answering balance questions.
- Security Without Servers: Querying the Google Analytics 4 Data API requires a Google Cloud service account with private RSA keys. If you query GA4 from client-side JavaScript in a browser, you must expose that private key to the world. By running our queries inside a scheduled GitHub Actions workflow, our credentials remain securely encrypted in repository secrets.
How the Pipeline Works
The entire pipeline runs without external hosting, databases, or paid serverless functions. It lives inside our repository:
[ Google Analytics 4 API ]
│
│ (Runs every 14 days at 00:00 UTC via GitHub Actions)
▼
[ scripts/sync-game-health.mjs ]
│
│ (JWT auth, discovers registered dimensions, queries the new period)
▼
[ assets/data/game-health/snapshots/<period_end>.json ] + index.json, history.csv, war-ledger.csv
│
│ (Git commit & push by github-actions[bot])
▼
[ GitHub Pages Build Pipeline ]
│
│ (Rebuilds and publishes static site)
▼
[ Client-Side Hydration on Blog Dashboard ]
1. The Zero-Dependency Sync Engine (scripts/sync-game-health.mjs)
Instead of dragging hundreds of megabytes of node_modules into GitHub Actions, we wrote sync-game-health.mjs using only Node.js native built-ins (node:crypto, node:fs, node:url):
- It signs an RSA-SHA256 JSON Web Token (JWT) directly using the service account’s private key.
- It exchanges the assertion for a Google OAuth2 access token.
- It asks GA4’s
metadataendpoint which custom dimensions are actually registered (for examplecommander_id,outcome,turns,challenger) rather than guessing names. - It calls
properties/{propertyId}:runReportfor the period since the last snapshot. A War ledger query (event_name = war_resolved) groups completed Wars by commander, outcome, turns, Battles, deficit, comeback, reinforcements, and anomalies. Separate queries cover reinforcement outcomes, surface engagement, achievements, and abandonment. - It derives the Google Play Game Stats it can (
wars_fought,wars_won,longest_war,battles_fought,reinforcements_sent, etc.). Anything GA4 cannot supply is written asnullwith a provenance note, never copied from an older period. - It writes an immutable snapshot, then regenerates the manifest, the CSV exports, and the legacy
game-health.json(now a copy of the latest snapshot).
2. The Bi-Weekly Cron (.github/workflows/sync-game-health.yml)
The workflow triggers on a standard cron schedule:
on:
schedule:
# Runs at 00:00 UTC on the 1st and 15th of every month
- cron: '0 0 1,15 * *'
workflow_dispatch: # Allows manual on-demand triggers
Before querying, the workflow runs the assembler’s node:test suite. If a new snapshot was produced, it commits the whole assets/data/game-health/ history under github-actions[bot] and pushes to main. That push immediately triggers the standard GitHub Pages deployment, making the new data live in under sixty seconds. A manual dispatch can also backfill an explicit period_start/period_end.
The Public Telemetry Contract
The data feeding our dashboard is public, transparent, and version-controlled under /assets/data/game-health/:
| File | Contents |
|---|---|
index.json |
Manifest of every stored period, the commander catalog, and the next scheduled sync |
snapshots/<period_end>.json |
One immutable snapshot per non-overlapping period, with per-section provenance |
history.csv |
One row of KPIs and per-commander results per period, ready for R, pandas, or a spreadsheet |
war-ledger.csv |
Completed Wars aggregated by commander, outcome, turns, Battles, deficit, comeback, reinforcements, and anomalies |
Three rules make the history safe to analyze:
- Periods never overlap. Counts can be summed across snapshots without double counting.
- Rates are recomputed, never averaged. A pooled win rate is pooled wins over pooled Wars.
- Missing means missing. If GA4 cannot supply a section for a period, it is
nullwith a note explaining why. It is not carried forward from an older snapshot.
The first stored period is the original 28-day exploration baseline (August 27 – September 23), marked ga4-exploration-baseline so it is never confused with API-sourced periods.
Dynamic Dashboard Hydration
The Game Health dashboard now renders entirely from this history. A Dataset switcher chooses between All periods combined and any single stored period. The Trends tab plots the human win rate and War pace per period. The Data & Provenance tab links every snapshot and CSV export. Each bi-weekly sync adds a period without anyone editing the post.
Current Baseline Standing (As of September 24, 2026)
- Total Completed Wars: 38
- Human Player Victories: 17 (44.7%)
- AI Commander Victories: 21 (55.3%)
- The Deadliest Opponent: Lorenzo di Taleggio (75.0% win rate) & Bastien de Herve (66.7% win rate)
- The Most Exploited AI: Matthias von Greyerz (14.3% win rate / 85.7% player victory rate)
- The Marathon Record: 43 turns in a single completed War
- The Rare Rule in Action: 11 opponent Aces felled directly by human Twos
What Happens Next
Our next scheduled telemetry sync runs on October 1, 2026. That run covers September 24–30 and becomes the first period queried directly from the GA4 Data API. From then on, a new period lands on the 1st and 15th of every month.
As more closed testers join the Mont-Rouge campaign and opt in to anonymous sharing, the sample will grow from dozens of Wars to hundreds. We’ll be watching to see whether human players continue to exploit Matthias’s rigid calculation, whether Marcel’s cheese cellar remains impenetrable, and whether anyone breaks the 43-turn marathon record on their way to the 51-turn ceiling.
View the Live Game Health Dashboard · Inspect the Snapshot History · Read the Development Series