Game Mechanics Reference
When to Use
- •Implementing a new card type or move card effect
- •Adding or debugging Fact operations (create, complicate, reveal, resolve)
- •Wiring scene progression or phase transitions
- •Debugging meter behaviour or scoring calculations
- •Understanding what happens at scene 9 / game end
Fact Lifecycle
See fact-lifecycle.md for the full specification.
TL;DR:
- •
elementcards → create aFact(isSecretifsubtype === 'secret') - •
Introducemove → create an open Fact - •
Complicatemove → append tofact.complications[], Tension +1 - •
Callbackmove → incrementfact.callbacks - •
Revealmove → setfact.revealed = true(secret facts only) - •
Resolvemove → costs 2 Tension; resolver +2 pts, creator +1 pt;fact.status = 'resolved'
Scoring
See scoring.md for the full specification.
TL;DR:
- •In-play points: Resolve = +2 to resolver, +1 to creator
- •Theme bonus: 0–3 pts per player, manually awarded in
scoringphase - •
player.totalScore = player.points + player.themeBonus - •Never auto-award theme bonuses
Scene Progression
Structure
- •9 scenes total: scenes 1–3 (Act 1), 4–6 (Act 2), 7–9 (Act 3)
- •Act is derived:
Math.ceil(sceneCount / 3)
Per-scene flow
- •Spotlight player is set at scene start (
spotlightIndex % players.length) - •Each player plays exactly one card (tracked via
currentScene.cardsPlayed) - •Scene completes when
cardsPlayed.length === players.length - •Phase transitions to
'scene-end'— players review the scene - •
nextScene()advances to the next scene or to'scoring'
Scene completion condition
const allPlayed = currentScene.cardsPlayed.length >= players.length;
Only allow nextScene() when allPlayed === true.
Scene 9 special rule
Before completing scene 9, a Resolve move must have been played:
const hasResolve = currentScene.cardsPlayed.some(
(cp) =>
players.flatMap((p) => p.hand).find((c) => c.id === cp.cardId)?.name ===
"Resolve",
);
// Or check via the log for the current scene
Store this as gameState.needsResolution: boolean — never recompute in screen components.
Phase transition map
setup → draft → play ↔ scene-end → play (scenes 1–8)
→ scoring (after scene 9)
scoring → end
No backward transitions except play ↔ scene-end.
Meters
| Meter | Min | Max | Effect tokens |
|---|---|---|---|
| Tension | 0 | 10 | +N Tension, -N Tension |
| Coherence | 0 | 10 | +N Coherence, -N Coherence |
Always cap with Math.min(10, Math.max(0, value)).
Card Rule Dispatch
Dispatched primarily by card.type, then by card.name for moves:
card.type === 'element' → create Fact (secret if subtype === 'secret') card.type === 'event' → parse effect string for meter tokens card.type === 'move' → dispatch by card.name: 'Introduce' → create open Fact 'Complicate' → add complication to target Fact, +1 Tension 'Reveal' → reveal a secret Fact 'Callback' → increment callbacks on target Fact 'Resolve' → resolve target Fact (-2 Tension, points) 'Confront' → scene climax mechanic (trigger ContradictionModal or NPC)
Effect String Parsing
Event effect strings use substring checks. Canonical tokens:
| Token | Effect |
|---|---|
+N Tension | tension += N |
-N Tension | tension -= N |
+N Coherence | coherence += N |
-N Coherence | coherence -= N |
Choose: A OR B | Player selects one option; only selected option applied |
Parse example:
function parseMeterDeltas(effect: string) {
const tensionMatch = effect.match(/([+-]\d+)\s*Tension/);
const coherenceMatch = effect.match(/([+-]\d+)\s*Coherence/);
return {
tension: tensionMatch ? parseInt(tensionMatch[1]) : 0,
coherence: coherenceMatch ? parseInt(coherenceMatch[1]) : 0,
};
}
For Choose: effects, parse only the selected option string, not the full effect.
Spotlight Rotation
const nextSpotlight = (prev.spotlightIndex + 1) % prev.players.length;
The spotlight player index is stored in gameState.spotlightIndex. Screens derive the current player name from players[spotlightIndex].name.