Building Legendary Arena: Designing an Authoritative Online Platform for Marvel Legendary

by | 2026 Mar 23 | Web Programming

Post Updated On: 2026 Mar 23
Table of Contents
2
3

Blog Post Search

Blog Post Categories

Follow Us

Feel free to follow us on social media for the latest news and more inspiration.

Why Legendary Arena?

Marvel Legendary is one of my favorite tabletop games. It’s deep, strategic, and highly replayable—but like many physical games, it’s fundamentally constrained by geography and setup time. I wanted to explore what it would take to bring that experience online without compromising the integrity of the game itself.

That question became the foundation for Legendary Arena:
a two‑player, online, turn‑based platform for Marvel Legendary built with modern web tooling and strict server authority.

This post isn’t about building a game UI. It’s about system design—how to structure an online game platform that is:

  • deterministic
  • cheat‑resistant
  • resumable after disconnects
  • scalable without real‑time sockets
  • cleanly separated by responsibility

In other words: how to build it right.


Core Design Philosophy

From the beginning, Legendary Arena followed a few non‑negotiable principles:

1. The server is always authoritative

Clients never decide outcomes. They can request moves, but the server validates everything.

2. Networking should be simple and robust

No WebSockets. No real‑time complexity. Just HTTP, done well.

3. Game rules are not UI code

Rules belong in a deterministic engine, not sprinkled across frontend logic.

4. Architecture should teach, not obscure

This project doubles as a public case study—clarity matters.


Technology Stack Overview

Legendary Arena is built on a deliberately boring (and reliable) stack:

  • Frontend: Vue 3 SPA (TypeScript, Vite, Pinia)
  • Game Engine: boardgame.io (authoritative turn‑based logic)
  • Backend: Node.js
  • API Gateway: RESI (Reverse‑proxy + policy layer)
  • Persistence: PostgreSQL
  • Networking: HTTP polling with ETag / 304 responses
  • Game Data: Master Strike (card rules + metadata)

Each piece has a single, well‑defined responsibility.


The Big Architectural Decision: Separation of Authority

The most important design choice in Legendary Arena is separating platform concerns from game concerns.

Two servers. One public API.

Legendary Arena uses two backend services:

  1. RESI API Gateway
  2. boardgame.io Game Server

Only RESI is exposed publicly.

Client (Vue SPA)
      |
      v
RESI API Gateway
      |
      +--> /api/v1/*        (platform endpoints)
      |
      +--> /api/v1/bgio/*   (reverse-proxied game engine)
                    |
                    v
         boardgame.io (Koa server)
                    |
                    v
                PostgreSQL

This gives us a clean split:

RESI owns:

  • authentication
  • rate limiting
  • input validation
  • logging & tracing
  • abuse protection
  • polling orchestration
  • room / lobby UX

boardgame.io owns:

  • game rules
  • move validation
  • turn order
  • phases
  • match lifecycle
  • authoritative state

The client never talks to the game engine directly.


Why boardgame.io?

boardgame.io is a purpose‑built framework for turn‑based games, and it solves several hard problems out of the box:

  • deterministic game state (G + ctx)
  • server‑validated moves
  • phase and turn management
  • built‑in multiplayer concepts
  • pluggable persistence

Most importantly: it enforces the idea that clients cannot cheat.

That makes it a perfect fit for a strategy‑heavy game like Marvel Legendary.


Networking Without WebSockets (Yes, Really)

Legendary Arena uses pure HTTP polling.

Before you recoil—this isn’t naive polling.

The polling strategy:

  • Clients poll for match state every 2–8 seconds depending on activity
  • Each response includes an ETag tied to the match version
  • Clients send If-None-Match on subsequent requests
  • If nothing changed, the server responds with 304 Not Modified
  • Zero payload. Zero JSON parsing.

This approach has several advantages:

  • firewall‑friendly
  • easy to scale horizontally
  • trivial to debug
  • resilient to disconnects
  • no sticky sessions
  • no socket state to recover

Is it real‑time? No.
Is it predictable, robust, and sufficient for turn‑based games? Absolutely.


Persistence as a First‑Class Feature

One of the goals of Legendary Arena is that games survive everything:

  • browser refresh
  • tab closure
  • brief network loss
  • server restarts

PostgreSQL is the system of record.

  • The game engine persists every authoritative state
  • Match metadata and state are stored server‑side
  • Clients can reconnect and resume at any time
  • No client‑side state is trusted

This also opens the door to:

  • replays
  • audits
  • analytics
  • future AI opponents

Master Strike: Data, Not Logic

Legendary Arena does not hardcode card rules in the UI.

Instead, it uses Master Strike, a structured card and rules database that powers deterministic gameplay.

Key design decision:

Every match pins a specific data version at creation time.

That means:

  • no mid‑match rule changes
  • no data drift
  • reproducible outcomes

This is a small decision with huge long‑term benefits.


What This Project Is (and Isn’t)

Legendary Arena is:

✅ a serious architecture experiment
✅ a teaching tool
✅ a case study in clean system boundaries
✅ a love letter to well‑designed board games

Legendary Arena is not:

  • a commercial product
  • an official implementation
  • a shortcut‑driven prototype

It’s about learning by building—and building correctly.


What’s Next?

Future directions include:

  • AI opponents powered by boardgame.io bots
  • Match history and replays
  • Observability dashboards
  • Scaling experiments
  • Optional WebSocket layer (behind feature flags)

But the foundation is intentionally boring—and that’s a compliment.


Final Thoughts

Building Legendary Arena reinforced something I believe deeply:

Good software architecture isn’t about novelty.
It’s about discipline, clarity, and respect for boundaries.

If you’re interested in system design, turn‑based games, or building resilient web platforms, I hope this case study helps—or at least sparks a few ideas.

You can find more engineering write‑ups like this at Barefoot Betters, where I document projects, experiments, and lessons learned—one build at a time.

The Game Engine: Rules, Data, and Determinism

Legendary Arena’s most important missing piece—by design—is the game engine itself.

The platform already defines where authority lives and how state moves across the network. What remains is the hardest part: encoding Marvel Legendary’s rules in a way that is deterministic, auditable, and faithful to the tabletop game.

To support that, Legendary Arena intentionally separates card data, card images, and game logic into distinct concerns.

Card Data: Master Strike

All structured card metadata—heroes, villains, masterminds, schemes, keywords, and rules text—comes from master‑strike.com, an unofficial but well‑maintained Legendary card database used by the community for research and setup. It provides normalized, machine‑readable representations of cards that are ideal for game logic and validation. [master-strike.com]

In Legendary Arena:

  • Master Strike data is treated as authoritative reference data, not executable logic
  • Each match pins a specific data version at creation time
  • All rules evaluation uses that pinned version to prevent mid‑match drift

This ensures every game is reproducible, debuggable, and consistent—even years later.

Card Images: LegendaryCardGame

High‑resolution card images are sourced separately from legendarycardgame.com, a community site that hosts card images organized by set and expansion. [legendaryc…rdgame.com]

Importantly:

  • Images are never embedded in the game engine
  • The engine works entirely on IDs and metadata
  • The client lazily loads images by URL for display only

This keeps the engine:

  • lightweight
  • testable
  • independent of presentation concerns

Designing the Legendary Arena Game Engine

The game engine will be implemented using boardgame.io, which provides the structural scaffolding—but not the game rules themselves.

What boardgame.io gives you

  • Deterministic state containers (G and ctx)
  • Turn order and phase control
  • Server‑side move validation
  • Authoritative execution
  • Persistence hooks

What you still must write

  • Setup logic (scheme, mastermind, villain deck composition)
  • Card effects
  • Keywords and triggered abilities
  • Master Strike resolution
  • Win/loss conditions
  • Edge‑case rules

This is intentional: Marvel Legendary is complex, and correctness matters more than speed.


Recommended Engine Architecture

The game engine should be structured as pure functions over immutable state, driven by data—not conditionals scattered throughout the codebase.

1. Data‑Driven State Model

Use G to hold only game state, never behavior:

G = {
  players: {
    "0": { deck, hand, discard, shards, wounds, score },
    "1": { deck, hand, discard, shards, wounds, score }
  },
  hq: CardId[],
  villainDeck: CardId[],
  mastermind: {
    id,
    tacticsRemaining
  },
  scheme: {
    id,
    twistsResolved
  },
  city: CardId[],
  koPile: CardId[]
}

All rule execution should derive behavior from card metadata, not hardcoded IDs.


2. Phases Reflect the Rulebook

Legendary already has implicit phases. Make them explicit:

  • setup
  • villain
  • playerTurn
  • masterStrike
  • cleanup
  • end

Each phase defines:

  • which moves are legal
  • who may act
  • automatic transitions

This dramatically reduces illegal states.


3. Moves Are Intent, Not Effects

Moves should express player intent, not outcomes:

playCard(cardId)
recruitHero(hqIndex)
fightVillain(cityIndex)

🚫 gainPower(5)
🚫 drawTwoCards()

The engine decides the effects using card metadata and current state.


4. Keywords as Composable Functions

Keywords (e.g., Hyperspeed, Wall‑Crawl, Ambush) should be implemented as pure, reusable handlers:

keywordHandlers["Hyperspeed"] = (ctx) => { ... }

Cards simply declare which keywords they have.

This avoids a combinatorial explosion of card‑specific logic.


5. Master Strike Is a First‑Class Phase

Master Strikes are not just “events”—they are global rule modifiers.

Treat them as:

  • a dedicated phase
  • driven by mastermind metadata
  • resolved entirely server‑side

This keeps the most complex rules auditable and deterministic.


Why This Engine Is Worth the Effort

Writing this engine will be the hardest part of Legendary Arena—but also the most valuable.

Done correctly, it enables:

  • cheat‑proof gameplay
  • replays and audits
  • AI players
  • variant rule sets
  • long‑term maintainability

And just as importantly, it keeps presentation concerns completely separate from rules and authority—a line many online game projects blur too early.


Legendary Arena Game Engine – Three Layers

┌────────────────────────────┐
│ 1. Static Reference Data   │  ← JSON (cards, keywords, sets)
├────────────────────────────┤
│ 2. Runtime Game State      │  ← JS objects (G, ctx)
├────────────────────────────┤
│ 3. Rules & Mechanics       │  ← TypeScript code
└────────────────────────────┘

1️⃣ Static Reference Data (YES, JSON)

This is where master‑strike.com shines.

You already have (or can derive):

✅ What belongs in JSON

  • Card IDs
  • Card types (hero, villain, mastermind, scheme, etc.)
  • Cost / attack / recruit values
  • Keywords (Hyperspeed, Ambush, etc.)
  • Textual rules (for reference)
  • Set / expansion metadata
  • Factions / teams

Example (simplified):

{
  "id": "hero-spiderman-01",
  "type": "hero",
  "cost": 2,
  "attack": 2,
  "recruit": 1,
  "keywords": ["Wall-Crawl"],
  "text": "You may recruit this card from the HQ."
}

✅ This data is descriptive, not executable
✅ You version‑pin it per match
✅ You never mutate it during play

You already have this layer.


2️⃣ Runtime Game State (NOT JSON FILES)

This is boardgame.io’s G and ctx, created at match setup and mutated during play.

✅ What lives in runtime state

  • Player decks / hands / discard piles
  • HQ cards
  • City villains
  • Mastermind tactics remaining
  • Scheme twists resolved
  • KO pile
  • Current turn / phase

Example:

G = {
  players: {
    "0": { deck, hand, discard, wounds, score },
    "1": { deck, hand, discard, wounds, score }
  },
  hq: ["hero-ironman-01", "hero-hulk-02"],
  city: ["villain-hydra-01"],
  mastermind: { id: "red-skull", tacticsLeft: 3 },
  scheme: { id: "negative-zone", twistsResolved: 2 }
}

✅ This is ephemeral ✅ It is persisted automatically by boardgame.io ✅ It is rebuilt on reconnect

You do not predefine this in JSON files.


3️⃣ Rules & Mechanics (CODE, NOT JSON)

This is the most critical part.

❌ What you should NOT do

  • Encode the entire rulebook as JSON
  • Write giant if/else trees keyed off card IDs
  • Treat rules text as executable logic

✅ What you SHOULD do

  • Translate rules into pure functions
  • Implement mechanics as code
  • Use JSON data to drive behavior, not replace it

Example: A Move

moves: {
  playCard: (G, ctx, cardId) => {
    const card = getCard(cardId);
    applyKeywords(card, G, ctx);
    applyCardEffect(card, G, ctx);
  }
}

The rulebook lives here — as logic.


✅ Do I Need to “Convert the Rulebook”?

No. You interpret it.

Think of the rulebook as:

  • a specification
  • a test oracle
  • a design document

Not as a dataset.

A useful analogy

DomainDataLogic
ChessBoard layoutMove rules
SQLSchemaQuery planner
HTTPHeadersSemantics
LegendaryCard metadataGame engine

You don’t JSON‑encode the rules of chess. You write a chess engine.


✅ What Data You ACTUALLY Need (Checklist)

✅ Already have / easy to get

  • Card metadata (Master Strike)
  • Card images (LegendaryCardGame)
  • Expansion sets
  • Keywords list

✅ You need to define

  • Initial deck composition rules
  • Scheme setup rules
  • Mastermind behavior
  • Win / loss conditions
  • Turn order & phases
  • Keyword semantics (as functions)

❌ You do NOT need

  • Rulebook JSON
  • “Executable rules text”
  • Card‑specific hardcoding

✅ Recommended Development Order (Critical)

This order matters.

Phase 1: Engine Skeleton

  • Setup()
  • Phases
  • Turn flow
  • Empty moves

Phase 2: Core Loop

  • Villain reveal
  • HQ refill
  • Player turn lifecycle

Phase 3: Basic Cards

  • No keywords
  • Simple attack / recruit

Phase 4: Keywords (Composable)

  • One keyword at a time
  • Tested independently

Phase 5: Master Strike Logic

  • Dedicated phase
  • Deterministic resolution

Phase 6: Edge Cases

  • KO rules
  • City overflow
  • Scheme twists

✅ The Big Insight (This Is the “Aha” Moment)

Legendary Arena’s engine is not a data problem.
It’s a compiler problem.

You are compiling:

  • card metadata
  • rulebook semantics

into:

  • deterministic state transitions

That’s why:

  • JSON alone isn’t enough
  • code is unavoidable
  • correctness matters more than speed

✅ Final Verdict

  • ✅ Card data → JSON ✅
  • ✅ Images → external URLs ✅
  • ❌ Rulebook → JSON ❌
  • ✅ Rulebook → TypeScript logic ✅

You are building a rules engine, not a rules database.

LegendaryGame.ts Skeleton

// packages/game/src/LegendaryGame.ts

import type { Game, Ctx, PlayerID } from 'boardgame.io';

/**
 * --- Core Concepts ---
 * boardgame.io maintains state in two objects:
 *  - G: game state you define (must be JSON-serializable)
 *  - ctx: metadata managed by boardgame.io (turn, phase, currentPlayer, etc.)
 * [2](https://willdan-my.sharepoint.com/personal/jjensen_willdan_com/Documents/Microsoft%20Copilot%20Chat%20Files/FlowchartMasterStrike.pdf?web=1)
 */

// --------------------
// Types / IDs
// --------------------

export type CardID = string;

export type Zone =
  | 'HQ'
  | 'CITY'
  | 'VILLAIN_DECK'
  | 'HERO_DECK'
  | 'WOUND_STACK'
  | 'BYSTANDER_STACK'
  | 'KO_PILE'
  | 'ESCAPED'
  | 'VICTORY_PILE'
  | 'PLAYER_DECK'
  | 'PLAYER_HAND'
  | 'PLAYER_DISCARD';

export type PhaseName =
  | 'setup'
  | 'villain'
  | 'player'
  | 'masterStrike'
  | 'cleanup'
  | 'end';

export type GameResult = {
  winner?: PlayerID;
  reason: 'mastermindDefeated' | 'schemeCompleted' | 'custom';
};

// --------------------
// Setup Data (match configuration)
// --------------------

/**
 * These are match parameters you choose before the game starts.
 * Inspired by your setup template (Scheme, Mastermind, Villain groups, Heroes, etc.). [1](https://willdan-my.sharepoint.com/personal/jjensen_willdan_com/Documents/Microsoft%20Copilot%20Chat%20Files/SetupDecksTemplate.pdf?web=1)
 */
export type SetupData = {
  dataVersion: string;            // pinned Master Strike dataset version
  seed?: string;                  // deterministic seed for shuffles (optional)
  schemeId: CardID;
  mastermindId: CardID;

  villainGroupIds: CardID[];      // usually 2 groups, but keep flexible
  henchmanGroupIds: CardID[];     // henchmen required by mastermind/scheme, etc.
  heroDeckIds: CardID[];          // usually 5 heroes in a standard game

  // optional knobs
  includeShieldOfficers?: boolean;
  includeSidekicks?: boolean;
  includeBystanders?: boolean;
  includeWounds?: boolean;

  // future: difficulty, house rules, drafting mode, etc.
};

// --------------------
// Player State
// --------------------

export type PlayerState = {
  deck: CardID[];
  hand: CardID[];
  discard: CardID[];
  victoryPile: CardID[];
  escaped: CardID[];

  // Per-turn resources
  recruit: number;
  attack: number;

  // Anything else you need (tokens, shards, etc.)
  // tokens: Record<string, number>;
};

// --------------------
// Board / Shared State
// --------------------

export type BoardState = {
  hq: CardID[];                 // face-up heroes available to recruit
  city: CardID[];               // villains in city spaces (front = closest to escape)
  koPile: CardID[];

  // The three “stacks”
  heroDeck: CardID[];           // “Hero deck” supply if you model it as a single stack
  villainDeck: CardID[];        // main villain deck (twists, strikes, villains, bystanders)
  woundStack: CardID[];

  bystanderStack: CardID[];

  // Scenario references
  scheme: { id: CardID; twistsResolved: number };
  mastermind: { id: CardID; tacticsRemaining: number };

  // bookkeeping
  turn: number;                 // optional mirror of ctx.turn for convenience
  log: string[];                // lightweight debug log (optional)
};

// --------------------
// Complete Game State (G)
// --------------------

export type LegendaryState = {
  config: SetupData;
  board: BoardState;
  players: Record<PlayerID, PlayerState>;

  // optional: derived caches / indices (keep JSON-serializable)
  // e.g. cardIndex: Record<CardID, { type: string; cost?: number }>;
};

// --------------------
// Helper utilities (pure functions only)
// --------------------

function emptyPlayerState(): PlayerState {
  return {
    deck: [],
    hand: [],
    discard: [],
    victoryPile: [],
    escaped: [],
    recruit: 0,
    attack: 0,
  };
}

function createInitialBoard(config: SetupData): BoardState {
  return {
    hq: [],
    city: [],
    koPile: [],
    heroDeck: [],
    villainDeck: [],
    woundStack: [],
    bystanderStack: [],
    scheme: { id: config.schemeId, twistsResolved: 0 },
    mastermind: { id: config.mastermindId, tacticsRemaining: 4 }, // TODO: derive from mastermind data
    turn: 0,
    log: [],
  };
}

/**
 * Deterministic shuffle:
 * boardgame.io offers plugin randomness; for now this is a placeholder.
 * In production, prefer boardgame.io's randomness plugin so replays stay deterministic.
 */
function shuffle<T>(arr: T[], rng: () => number): T[] {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rng() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

function log(G: LegendaryState, msg: string) {
  G.board.log.push(msg);
}

// --------------------
// Engine Hooks (TODO integration points)
// --------------------

/**
 * TODO: load Master Strike card metadata by ID + pinned dataVersion.
 * You already have a master-strike-data dist structure in your repo flowcharts. [2](https://willdan-my.sharepoint.com/personal/jjensen_willdan_com/Documents/Microsoft%20Copilot%20Chat%20Files/FlowchartMasterStrike.pdf?web=1)[3](https://willdan-my.sharepoint.com/personal/jjensen_willdan_com/Documents/Microsoft%20Copilot%20Chat%20Files/FlowchartMasterStrikeLetter.pdf?web=1)
 */
function getCardMeta(_dataVersion: string, _id: CardID) {
  // return { type, cost, keywords, text, ... }
  return null as any;
}

/**
 * TODO: Build villain deck per scheme/mastermind rules.
 * Your setup template explicitly lists the deck components (scheme, mastermind, villain groups, henchmen, bystanders, wounds, heroes, SHIELD, etc.). [1](https://willdan-my.sharepoint.com/personal/jjensen_willdan_com/Documents/Microsoft%20Copilot%20Chat%20Files/SetupDecksTemplate.pdf?web=1)
 */
function buildVillainDeck(_config: SetupData): CardID[] {
  // placeholder
  return [];
}

/**
 * TODO: Build initial player decks (starting SHIELD agents/troopers, etc.)
 */
function buildStartingDeck(_playerId: PlayerID, _config: SetupData): CardID[] {
  return [];
}

/**
 * TODO: Fill HQ to 5 cards, refill when hero recruited/KO'd, etc.
 */
function refillHQ(G: LegendaryState, ctx: Ctx) {
  while (G.board.hq.length < 5 && G.board.heroDeck.length > 0) {
    const card = G.board.heroDeck.pop()!;
    G.board.hq.push(card);
  }
  log(G, `HQ refilled by ${ctx.currentPlayer}`);
}

/**
 * TODO: City overflow → villain escapes and triggers escape effect.
 */
function enforceCityLimit(G: LegendaryState) {
  const CITY_LIMIT = 5;
  while (G.board.city.length > CITY_LIMIT) {
    const escaped = G.board.city.pop()!;
    // TODO: apply escape effect based on villain metadata
    log(G, `Villain escaped: ${escaped}`);
  }
}

// --------------------
// Moves (player intent)
// --------------------

export type MoveArgs =
  | { type: 'playCard'; cardId: CardID }
  | { type: 'recruitHero'; hqIndex: number }
  | { type: 'fightVillain'; cityIndex: number }
  | { type: 'endTurn' };

function assertPlayer(G: LegendaryState, ctx: Ctx): PlayerState {
  return G.players[ctx.currentPlayer];
}

// --------------------
// LegendaryGame definition
// --------------------

export const LegendaryGame: Game<LegendaryState, SetupData> = {
  name: 'legendary-arena',

  minPlayers: 2,
  maxPlayers: 2,

  setup: (ctx: Ctx, setupData: SetupData): LegendaryState => {
    const config = setupData;

    const G: LegendaryState = {
      config,
      board: createInitialBoard(config),
      players: {
        '0': emptyPlayerState(),
        '1': emptyPlayerState(),
      },
    };

    // TODO: seed RNG deterministically (use boardgame.io random plugin in production)
    const rng = () => Math.random();

    // Build decks
    G.board.villainDeck = buildVillainDeck(config);

    // Build hero deck supply (simplified placeholder)
    // TODO: build hero deck from selected heroDeckIds + counts, then shuffle.
    G.board.heroDeck = shuffle([...config.heroDeckIds], rng);

    // Optional stacks
    if (config.includeWounds) G.board.woundStack = [];        // TODO fill with wound IDs
    if (config.includeBystanders) G.board.bystanderStack = []; // TODO fill with bystander IDs

    // Starting decks
    for (const pid of Object.keys(G.players) as PlayerID[]) {
      const starter = shuffle(buildStartingDeck(pid, config), rng);
      G.players[pid].deck = starter;

      // TODO: draw starting hand size
      // drawCards(G, pid, 6);
    }

    // Fill HQ
    refillHQ(G, ctx);

    log(G, 'Game setup complete');
    return G;
  },

  phases: {
    setup: {
      start: true,
      // onBegin could do any derived initialization if needed
      next: 'villain',
    },

    villain: {
      onBegin: (G: LegendaryState, ctx: Ctx) => {
        // TODO: reveal top villain deck card, resolve:
        // - Villain enters city
        // - Scheme Twist
        // - Master Strike
        // - Bystander / Wound interactions
        // Then enforce city overflow rules
        G.board.turn = ctx.turn;
        log(G, 'Villain phase begin');

        // placeholder: decide if next phase should be masterStrike
        // In a real implementation, you'd inspect the revealed card type.
      },
      endIf: () => true,
      next: 'player',
    },

    masterStrike: {
      // Optional: you can enter this phase only when a Master Strike is revealed
      onBegin: (G: LegendaryState, ctx: Ctx) => {
        log(G, 'Master Strike phase begin');
        // TODO: resolve mastermind strike effect deterministically
        enforceCityLimit(G);
      },
      endIf: () => true,
      next: 'player',
    },

    player: {
      onBegin: (G: LegendaryState, ctx: Ctx) => {
        const p = assertPlayer(G, ctx);
        p.recruit = 0;
        p.attack = 0;
        log(G, `Player phase begin: ${ctx.currentPlayer}`);
      },

      moves: {
        playCard: (G: LegendaryState, ctx: Ctx, cardId: CardID) => {
          const p = assertPlayer(G, ctx);

          // TODO: validate card is in hand
          // TODO: apply card effects + keywords
          // TODO: update recruit/attack, draw, KO, etc.
          log(G, `playCard(${cardId}) by ${ctx.currentPlayer}`);

          // placeholder: remove from hand -> discard
          const idx = p.hand.indexOf(cardId);
          if (idx >= 0) {
            p.hand.splice(idx, 1);
            p.discard.push(cardId);
          }
        },

        recruitHero: (G: LegendaryState, ctx: Ctx, hqIndex: number) => {
          // TODO: validate recruit points, move card to discard, refill HQ
          const p = assertPlayer(G, ctx);
          const cardId = G.board.hq[hqIndex];
          if (!cardId) return;

          log(G, `recruitHero(HQ[${hqIndex}] = ${cardId}) by ${ctx.currentPlayer}`);
          // TODO: cost check from metadata
          // const meta = getCardMeta(G.config.dataVersion, cardId);
          // if (p.recruit < meta.cost) return;

          // placeholder
          G.board.hq.splice(hqIndex, 1);
          p.discard.push(cardId);
          refillHQ(G, ctx);
        },

        fightVillain: (G: LegendaryState, ctx: Ctx, cityIndex: number) => {
          // TODO: validate attack points, defeat villain, resolve fight effects
          const p = assertPlayer(G, ctx);
          const cardId = G.board.city[cityIndex];
          if (!cardId) return;

          log(G, `fightVillain(CITY[${cityIndex}] = ${cardId}) by ${ctx.currentPlayer}`);

          // placeholder: remove villain -> victory pile
          G.board.city.splice(cityIndex, 1);
          p.victoryPile.push(cardId);
        },

        endTurn: (G: LegendaryState, ctx: Ctx) => {
          log(G, `endTurn by ${ctx.currentPlayer}`);
          // TODO: discard hand, draw new hand, cleanup HQ/city if needed
          // ctx.events?.endTurn(); // (if using events plugin / boardgame.io default)
        },
      },

      endIf: () => true,
      next: 'cleanup',
    },

    cleanup: {
      onBegin: (G: LegendaryState, ctx: Ctx) => {
        log(G, 'Cleanup phase begin');
        // TODO: discard remaining played cards, reset resources, draw next hand
      },
      endIf: () => true,
      next: 'villain',
    },

    end: {
      // endIf at root can also be used
    },
  },

  /**
   * Check for game end conditions.
   * - mastermind defeated
   * - scheme completed
   * - custom loss conditions
   */
  endIf: (G: LegendaryState, _ctx: Ctx): GameResult | undefined => {
    // TODO: derive from scheme/mastermind state
    // if (G.board.mastermind.tacticsRemaining <= 0) return { winner: '0', reason: 'mastermindDefeated' };
    // if (G.board.scheme.twistsResolved >= maxTwists) return { reason: 'schemeCompleted' };
    return undefined;
  },
};

What Comes Next

Legendary Arena is intentionally being built inside‑out:

  1. Platform architecture ✅
  2. Authority & persistence ✅
  3. Data modeling ✅
  4. Game engine ⏳
  5. UI polish later

That order matters.

The goal isn’t to ship fast—it’s to build something that can survive scrutiny, scale, and time.

Layer 0 – Card Standards

✅ Canonical card IDs
✅ Standardized image filenames
✅ Clean, consistent card JSON
✅ Stable asset hosting (Cloudflare R2)

This work is not wasted effort. In fact, it unlocks everything else.

Think of this as Layer 0 of Legendary Arena.

✅ The Next Logical Step (After Assets & Data)

The next step is not writing the full game engine.

The next step is:

🔑 Build a minimal “Card Data Access Layer” and stop.

This is a small, contained, low‑stress step that creates momentum without committing you to rules logic yet.


Step 1 (Next): Create a Card Registry Module ✅

Once your data + images are clean and in R2, the next thing you should build is:

A read‑only Card Registry

Its job:

  • Load card JSON
  • Resolve a card by ID
  • Provide image URLs
  • Provide basic metadata (type, cost, keywords)
  • Nothing else

Example responsibility (conceptual)

getCard(cardId)        → metadata
getCardImage(cardId)  → R2 URL
listCardsByType(type)

✅ No game rules
✅ No boardgame.io
✅ No turns, phases, or moves
✅ No state mutation

This is pure data plumbing.


Why This Is the Correct Next Step

1. It reduces cognitive load

You only think about:

  • IDs
  • data shape
  • consistency

Not:

  • turns
  • edge cases
  • rules interpretation

2. It proves your data work is correct

You immediately validate:

  • Are IDs stable?
  • Are images named correctly?
  • Does every card resolve?
  • Are there missing assets?

If something’s wrong, you catch it now, not buried inside game logic.


3. It becomes the foundation for everything

Later:

  • the game engine uses it
  • the UI uses it
  • tests use it
  • setup screens use it

This module will likely never change much again.

1) Card Registry Interface (TypeScript)

Goals

  • Read-only (no game engine rules)
  • Stable IDs (your standardized naming work pays off)
  • Decoupled from storage (R2 now, could be S3/CDN later)
  • Fast lookups + basic filtering
  • Single module imported by both UI and later the server

Recommended file placement

packages/registry/src/index.ts

// packages/registry/src/index.ts

export type CardID = string;

export type CardType =
  | 'hero'
  | 'villain'
  | 'henchmen'
  | 'mastermind'
  | 'scheme'
  | 'bystander'
  | 'wound'
  | 'officer'
  | 'sidekick'
  | 'other';

export type CardSet = {
  id: string;          // e.g. "core", "dark-city"
  name: string;        // display label
  release?: string;    // optional
};

export type CardImageVariant = 'thumb' | 'standard' | 'hires';

export type CardImageRef = {
  /** canonical URL (likely Cloudflare R2 public URL behind CDN) */
  url: string;
  /** stable filename you standardized */
  fileName: string;
  /** optional dimensions if known (helps layout) */
  width?: number;
  height?: number;
  /** optional content hash for cache busting integrity */
  sha256?: string;
};

export type Card = {
  id: CardID;

  // taxonomy
  type: CardType;
  setId: string;          // ties back to CardSet.id
  setName?: string;       // optional denormalized label
  name: string;

  // gameplay metadata (reference only — not executable rules)
  cost?: number;
  attack?: number;
  recruit?: number;

  // rule text as reference (not executable)
  text?: string;

  // keywords as normalized tokens (from master-strike metadata)
  keywords?: string[];

  // additional structured metadata (teams, classes, etc.)
  tags?: string[];        // optional, your own classification
  teams?: string[];
  classes?: string[];

  // image mapping (decoupled from rules)
  images?: Partial<Record<CardImageVariant, CardImageRef>>;

  // keep original source for traceability (optional but super helpful)
  source?: {
    provider: 'master-strike' | 'manual' | 'import';
    dataVersion: string;     // pinned version (matches your dataset versioning plan)
    raw?: unknown;           // optional raw object for debugging
  };
};

export type CardQuery = {
  text?: string;              // name/text search
  type?: CardType | CardType[];
  setId?: string | string[];
  keyword?: string | string[];
  tag?: string | string[];
  limit?: number;
  offset?: number;
};

export type CardRegistryInfo = {
  dataVersion: string;
  builtAt: string;            // ISO timestamp when registry built
  cardCount: number;

  // optional diagnostics
  missingImages?: number;
  duplicateNames?: number;

  // configured base urls (handy for debugging)
  imageBaseUrl?: string;
};

export interface CardRegistry {
  /** Summary info for diagnostics & UI headers */
  info(): Promise<CardRegistryInfo>;

  /** Deterministic list of all IDs (for audits) */
  listIds(): Promise<CardID[]>;

  /** Get a single card (the #1 most important function) */
  get(id: CardID): Promise<Card | undefined>;

  /** Batch fetch (UI grids love this) */
  getMany(ids: CardID[]): Promise<Card[]>;

  /** Query / filter */
  query(q: CardQuery): Promise<{ total: number; results: Card[] }>;

  /** Image resolution helper (so UI never builds URLs manually) */
  getImageUrl(id: CardID, variant?: CardImageVariant): Promise<string | undefined>;

  /** Validation helpers (useful while you standardize assets) */
  validate(): Promise<{
    ok: boolean;
    errors: Array<{ code: string; message: string; cardId?: CardID }>;
    stats: { missingImages: number; totalCards: number };
  }>;
}

Why this interface works for you


2) Minimal Registry Implementation Strategy (Two options)

You asked for “the ability to view it.” That implies we should make the registry usable from:

the client (Vue page)
✅ optionally an API endpoint (RESI can serve it)

Here are the two cleanest options:


Option A (Fastest): Client-only registry viewer

  • The Vue app loads a single cards.json index (from R2 or local build output)
  • Viewer page shows:
    • filters (type, set, keyword)
    • card list
    • click = details panel + image

This is consistent with your earlier SPA flow: load static assets and fetch images on demand. [FlowchartM…sterStrike | PDF], [FlowchartM…rikeLetter | PDF], [FlowchartM…trikeVuSon | PDF]

Viewer route suggestion

/registry or /cards

Minimal pages/components

  • RegistryView.vue (filters + results)
  • CardDetailPanel.vue (card data + image)
  • useRegistry() composable

Option B (Best long-term): RESI exposes /api/v1/registry/*

This is still simple and helps later when the game server also needs the same data.

Suggested endpoints (read-only)

  • GET /api/v1/registry/info
  • GET /api/v1/registry/cards/:id
  • GET /api/v1/registry/cards?type=hero&setId=core&q=spider

The Vue viewer calls these endpoints.

This approach also makes it easy to enforce caching, ETags, etc.


3) “Ability to View It” — A Concrete Vue Viewer Skeleton

Here’s a minimal Viewer UX that gives you immediate payoff with almost no logic:

What it should show

  1. a table/grid of cards (name, type, set, keywords)
  2. a detail pane (JSON + image)
  3. a “missing image” filter (to help you finish your cleanup)

This directly supports your current standardization mission.


RegistryView.vue (conceptual skeleton)

Why this interface works for you

It aligns with your current pattern: “load metadata/definitions, then fetch images on demand.” [FlowchartM...sterStrike | PDF], [FlowchartM...rikeLetter | PDF], [FlowchartM...trikeVuSon | PDF]
It’s engine-agnostic (safe while you’re overwhelmed).
It supports your R2 migration by putting all URL logic behind getImageUrl.


2) Minimal Registry Implementation Strategy (Two options)
You asked for “the ability to view it.” That implies we should make the registry usable from:
✅ the client (Vue page)
✅ optionally an API endpoint (RESI can serve it)
Here are the two cleanest options:

Option A (Fastest): Client-only registry viewer

The Vue app loads a single cards.json index (from R2 or local build output)
Viewer page shows:

filters (type, set, keyword)
card list
click = details panel + image



This is consistent with your earlier SPA flow: load static assets and fetch images on demand. [FlowchartM...sterStrike | PDF], [FlowchartM...rikeLetter | PDF], [FlowchartM...trikeVuSon | PDF]
Viewer route suggestion
/registry or /cards
Minimal pages/components

RegistryView.vue (filters + results)
CardDetailPanel.vue (card data + image)
useRegistry() composable


Option B (Best long-term): RESI exposes /api/v1/registry/*
This is still simple and helps later when the game server also needs the same data.
Suggested endpoints (read-only)

GET /api/v1/registry/info
GET /api/v1/registry/cards/:id
GET /api/v1/registry/cards?type=hero&setId=core&q=spider

The Vue viewer calls these endpoints.
This approach also makes it easy to enforce caching, ETags, etc.

3) “Ability to View It” — A Concrete Vue Viewer Skeleton
Here’s a minimal Viewer UX that gives you immediate payoff with almost no logic:
What it should show

a table/grid of cards (name, type, set, keywords)
a detail pane (JSON + image)
a “missing image” filter (to help you finish your cleanup)

This directly supports your current standardization mission.

RegistryView.vue (conceptual skeleton)

The registryClient just implements the interface and pulls JSON from R2 or RESI.


4) Practical checklist (keeps you un-overwhelmed)

Once your data/images are done, your next “registry” steps can be:

✅ Step 1 — Create CardRegistry types (copy/paste above)

✅ Step 2 — Build registryClient that loads cards.json + resolves image URLs from your R2 base

✅ Step 3 — Add /registry page that:

  • lists cards
  • shows images
  • highlights missing/bad URLs

That’s it. No engine. No boardgame.io.


5) Grounding in your existing project artifacts

Your flow diagrams explicitly show:

So the registry design above is intentionally compatible with that pattern:

  • you can swap public/CardImages for Cloudflare R2 base URLs without touching UI logic again.

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *

Related Blog Posts