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_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 |
Set via:
Never expose in client bundle. Never in wrangler.toml.