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 Score capped server-side at 1.0. Worker independently bounds the modifier regardless of submitted score value
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: random 32-bit integer
   - issued_at: now
   - expires_at: now + 300 (5 minutes)
   - min_duration_ms: type-dependent (see catalog)
   - max_duration_ms: type-dependent (see catalog)
   - Signs with HMAC-SHA256 using MINIGAME_SECRET

4. Worker stores session in D1 (minigame_session table, expires_at set)

5. Worker returns signed token to client:
   { token, seed, type, min_duration_ms, max_duration_ms }

6. Client plays minigame using seed (deterministic content generation)

7. Client submits POST /api/minigame/result
   Body: { token, score, duration_ms, event_hash }

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 | sigil | price | patience",
  "activity_type": "expedition_accept | craft | gather | fish | trade | ...",
  "activity_ref_id": "board_item_id or template_id",
  "seed": 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.

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. Check score in [0.0, 1.0]; clamp if out of range
7. Derive expected_event_hash from seed using same algorithm as client
   Check submitted event_hash matches (±tolerance)
   Mismatch: 400 "invalid_event_hash"
8. Mark session used in D1
9. Compute modifier = clamp(0.7 + score * 0.7, 0.7, 1.4)
10. Write minigame_result row
11. Return result_token

Event Hash

Provides lightweight proof that the client actually ran the minigame content derived from the seed. It is NOT a full audit trail — it is a plausibility check.

Client computes:

// Collect key game events during play:
// e.g. for rhythm game: array of { beat_index, hit_offset_ms }
// for whack: array of { tile_index, hit_at_ms }
const events = [...];  // collected during play

// Deterministically sort by tick/index
const sorted = events.sort((a, b) => a.beat_index - b.beat_index || a.tile_index - b.tile_index);

// Hash: SHA-256 of seed + count + sum of indices
const hashInput = `${seed}:${events.length}:${sorted.map(e => e.beat_index ?? e.tile_index).join(',')}`;
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(hashInput));
const event_hash = btoa(String.fromCharCode(...new Uint8Array(hash)));

Server derives: Using the same seed and the submitted event count, the server re-derives the expected indices (it knows what tiles/beats were generated from the seed) and computes the expected hash. It checks that submitted hash matches within the expected count range.

This defeats fabricated scores where the attacker invents a result without running the minigame — they would need to reverse-engineer the seed's content generation to produce a plausible hash.


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
sigil 6000 60000 Pattern complexity varies
price 5000 20000 Fixed number of swings
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

Set via:

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

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