Minigame Architecture and Security¶
Overview¶
Active activities in Aelghar Interactive require a completed minigame session before the Worker processes them. Passive skills do not — they feed the settlement pass as probability inputs without a minigame gate of their own.
Active activities are those the player deliberately initiates with a discrete trigger: expedition departure, crafting, gathering, taming, ritual casting, trading, healing. Passive skills (Endurance, Heavy Armor, Routefinding, Campcraft, Weather Sense, Leadership, etc.) improve outcomes across other activities but never produce a minigame session.
See Activity → Minigame Map for the full active/passive taxonomy.
The session flow: server issues a signed challenge token → client plays the minigame → client submits score → server validates → settlement pass applies the probability modifier.
Threat Model¶
The minigame runs in the browser. The server cannot observe gameplay directly. Threats:
| Threat | Mitigation |
|---|---|
| Send fabricated score without playing | Session token includes a server-signed challenge. Invalid signature → immediate 401 reject |
| Play once, replay the same token forever | Token has 5-minute expiry, bound to character_id and activity_type. One use only — token burned on first valid submission |
| Automate the minigame perfectly | Duration bounds enforce minimum play time. Score plausibility range rejects superhuman results. Modifier ceiling limits the value of perfect automation |
| Replay a previously successful result | Token includes a nonce (jti). Worker marks it used in minigame_result table. Duplicate token = 409 |
| Client-side score inflation before submission | Client no longer submits a score. Worker derives score server-side from replayed events |
| Fabricate events matching submitted hash using known seed | Split seed: S1 (in token) drives target rendering only. S2 (KV-only, never sent) drives scoring tolerance offsets. Attacker cannot predict S2 offsets, so scripted events aimed at S1-derived centers score unpredictably |
| Extract session token and share it | Token bound to character_id from the active JWT. Mismatched character = 403 |
Session Lifecycle¶
1. Client calls POST /api/minigame/session
Body: { activity_type, activity_ref_id }
2. Worker validates: active JWT, character has valid loadout/state for activity
3. Worker generates:
- jti: crypto.randomUUID()
- seed_s1: random 32-bit integer (public — drives target/beat rendering on client)
- seed_s2: random 32-bit integer (private — drives scoring tolerance offsets; never sent to client)
- issued_at: now
- expires_at: now + 300 (5 minutes)
- min_duration_ms: type-dependent (see catalog)
- max_duration_ms: type-dependent (see catalog)
- Signs token with HMAC-SHA256 using MINIGAME_SECRET
- Stores seed_s2 in KV: key = `s2:{jti}`, value = seed_s2, TTL = 360s
4. Worker stores session in D1 (minigame_session table, expires_at set)
5. Worker returns signed token to client:
{ token, seed_s1, type, min_duration_ms, max_duration_ms }
6. Client plays minigame using seed_s1 (deterministic content generation)
7. Client submits POST /api/minigame/result
Body: { token, events[], duration_ms }
8. Worker validates (see Validation section)
9. If valid: stores result in minigame_result table, marks session used
10. Client uses result_token from response in the subsequent activity action
(board accept, craft command, etc.) as Minigame-Result header
11. Activity Worker validates result_token before executing business logic
Token Structure¶
Session Token¶
JWT-style signed string. Payload (before HMAC):
{
"jti": "uuid",
"sub": "character_id",
"type": "rhythm | whack | sequence | fishing | recipe | spot | harvest | maze | steady | patience",
"activity_type": "expedition_accept | craft | gather | fish | trade | ...",
"activity_ref_id": "board_item_id or template_id",
"seed_s1": 2147483647,
"min_duration_ms": 8000,
"max_duration_ms": 90000,
"iat": 1716000000,
"exp": 1716000300
}
Signed: base64url(payload) + "." + base64url(HMAC-SHA256(base64url(payload), MINIGAME_SECRET))
Not a standard JWT — custom signing to keep Worker code minimal.
seed_s2 is not in the token. It is stored in KV under s2:{jti} with a 360-second TTL and is never accessible to the client.
Result Token¶
After successful submission, Worker returns a short-lived result token:
{
"jti": "same as session jti",
"sub": "character_id",
"score": 0.72,
"modifier": 1.204,
"expires_at": 1716000600
}
Signed with same MINIGAME_SECRET. Expires 10 minutes after issue — enough time to confirm and submit the activity action. One-use: burned on first activity action submission.
Worker Validation (Step 8)¶
1. Decode and verify HMAC signature on submitted token
2. Check exp: reject if expired
3. Check D1: SELECT used FROM minigame_session WHERE jti = ?
- Not found: 404 (session never issued or already pruned)
- used = 1: 409 (already consumed)
4. Check sub matches character_id from active JWT
5. Check duration_ms >= min_duration_ms AND <= max_duration_ms
- Under: 400 "duration_too_short" (instant-solve bot rejection)
- Over: 400 "duration_too_long" (stale session)
6. Fetch seed_s2 from KV: key = `s2:{jti}`
- Missing or expired: 400 "session_s2_missing"
7. Validate event timing plausibility (see Server-Side Score Recomputation):
- Events strictly monotonically increasing
- No two consecutive events < 80ms apart
- Event count within [min_events, max_events] for the minigame type
- Total event span within ±500ms of submitted duration_ms
- Fail: 400 "implausible_timing"
8. Call replayScore(type, seed_s1, seed_s2, events[]) → score ∈ [0.0, 1.0]
Score is entirely server-derived. No client-submitted score field exists.
9. Mark session used in D1
10. Compute modifier = clamp(0.7 + score * 0.7, 0.7, 1.4)
11. Write minigame_result row (score, duration_ms, character_id, type, submitted_at)
12. Run anomaly check (see Behavioral Anomaly Detection)
13. Return result_token
Server-Side Score Recomputation¶
The server never trusts a client-submitted score. On result submission, the Worker independently computes the score from the submitted event log using the split seed.
Split Seed Design¶
| Seed | Location | Purpose |
|---|---|---|
seed_s1 |
Session token (client-visible) | Drives target positions, beat timings, tile layout — everything the player sees and interacts with |
seed_s2 |
KV only, TTL 360s (never sent to client) | Drives per-event scoring tolerance offsets — the hidden precision windows that determine whether a hit counts |
An attacker who reverse-engineers S1 knows exactly where every target appears and when every beat fires. They still cannot pre-compute which events will score — S2 shifts each hit window by a small unpredictable offset. Legitimate human play (aiming at visible targets) scores correctly regardless of S2. Scripted events aimed at the mathematical center of S1-derived windows are penalized by S2 variance.
replayScore Function¶
replayScore(type, s1, s2, events[]) → score ∈ [0.0, 1.0]
Pure deterministic function — no I/O, no randomness at call time.
1. Reconstruct expected event targets from s1:
Same PRNG/algorithm the client Phaser scene used to generate targets.
Result: list of { expected_position, expected_time } for each required event.
2. Apply s2-derived tolerance offsets:
For each target, derive a per-target offset from s2 (seeded PRNG step i).
Adjust hit window center and radius by the offset.
Offsets are small (±10ms timing, ±8% zone radius) — imperceptible to humans,
but break scripted events aimed at exact S1 centers.
3. Score each event in events[]:
Find nearest expected target (by time and position).
Check if event falls within the s2-adjusted window.
Hit counts toward score. Miss does not.
4. score = valid_hits / total_required_events (clamped to [0.0, 1.0])
Timing Plausibility Assertions¶
Checked before replay. Any failure → 400 implausible_timing.
| Check | Rule |
|---|---|
| Monotonic timestamps | Each event timestamp strictly greater than previous |
| Minimum spacing | No two consecutive events < 80ms apart |
| Event count | Within [min_events, max_events] for the minigame type |
| Span vs duration | last_event_ts − first_event_ts within ±500ms of submitted duration_ms |
Behavioral Anomaly Detection¶
After writing the minigame_result row, the Worker runs a lightweight D1 query to detect statistically improbable score sequences:
SELECT AVG(score) AS baseline, COUNT(*) AS n
FROM (
SELECT score FROM minigame_result
WHERE character_id = ? AND minigame_type = ?
AND submitted_at > unixepoch() - 86400
ORDER BY submitted_at DESC
LIMIT 30
)
If n >= 10 and the current session score exceeds baseline + 0.35, the Worker increments a suspicious_streak counter on the character's minigame_stats row. Three consecutive streaks → sets suspect_flag = 1.
Flagged characters:
- Modifier hard-clamped to 1.0 (neutral) regardless of score
- Flag reviewed and cleared manually by moderation
- Flag does not affect passive skill progression
The 1.4× modifier ceiling means the maximum economic impact of any minigame abuse is bounded regardless of whether this layer has activated.
Duration Bounds Per Type¶
| Minigame type | min_duration_ms | max_duration_ms | Notes |
|---|---|---|---|
rhythm |
8000 | 45000 | 8-second minimum for a rhythm track |
whack |
5000 | 30000 | Tile game always runs to fixed time |
sequence |
6000 | 60000 | Simon Says; harder rounds take longer |
fishing |
5000 | 90000 | Fish bite timing varies |
recipe |
8000 | 45000 | Recipe complexity varies by tier |
spot |
5000 | 30000 | Fixed observation window |
harvest |
5000 | 20000 | Fast-tap game |
maze |
10000 | 120000 | Route complexity varies |
steady |
8000 | 40000 | Sequence of target circles |
patience |
8000 | 60000 | Animal trust meter |
Skill Band Influence on Minigame Parameters¶
Higher skill band = more forgiving minigame for that activity type.
| Band | Effect |
|---|---|
| Untrained | Default difficulty (standard seed content) |
| Familiar | Hit window +10% wider; grid targets +10% larger |
| Practiced | Hit window +20%; targets +20%; one free miss |
| Skilled | Hit window +30%; fewer total required events; bonus start score 0.05 |
| Veteran | Hit window +40%; significantly reduced required precision; bonus start score 0.10 |
| Expert | Hit window +50%; minimal required events; bonus start score 0.15 |
| Master | Hit window +60%; very relaxed; bonus start score 0.20 |
Difficulty parameters are encoded in the session token by the Worker using the character's current skill band for that activity type. The Phaser client reads them from the token and configures the scene accordingly.
Skipping Minigames (AFK / Accessibility Mode)¶
Players may opt into Passive Mode per-character via account settings. In Passive Mode:
- No minigame required
- Activity proceeds with
score = 0.5(modifier = 1.0 — neutral) - The activity resolves exactly as in the base Aelghar game
- Passive Mode is stored as a
character_flagand respected by the session issuer Worker - Passive Mode players are excluded from leaderboard categories that track minigame performance
This ensures accessibility without compromising the security model — Passive Mode is a server-side flag, not a client bypass.
Secret Binding¶
| Secret name | Purpose |
|---|---|
MINIGAME_SECRET |
HMAC-SHA256 signing key for session and result tokens |
MINIGAME_KV |
KV namespace binding — stores s2:{jti} entries with 360s TTL |
Set via:
Never expose in client bundle. Never in wrangler.toml.