Skip to content

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_flag and 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:

wrangler secret put MINIGAME_SECRET
wrangler secret put MINIGAME_SECRET --env staging

Never expose in client bundle. Never in wrangler.toml.