# 8. Discord Display, Visual Identity, and Animated Cards

[< Back to Index](README.md) | [Next: Integrations >](09-integrations.md)

---

## 8.1 Tier Visual Indicators

Evolved cards display visual indicators in Discord embeds that signal evolution status without
requiring image regeneration (which is expensive and slow):

| Evolution State | Embed Change |
|---|---|
| Not evolved (T0) | Standard embed, no change |
| T1 complete | Small badge emoji appended to card name field: `Mike Trout [T1]` |
| T2 complete | Badge updates: `Mike Trout [T2]` + embed accent color changes to gold |
| T3 complete | Badge: `Mike Trout [T3]` + embed color changes to purple |
| T4 full evolved | Name changes to `[Team]'s Evolved Mike Trout` + embed color changes to teal |

> **TBD:** Tier badge icons are placeholders and likely to change. Current candidates:
> T1 = `:seedling:`, T2 = `:star:`, T3 = `:gem:`, T4 = `:crown:`. Final icons will be
> chosen during implementation based on Discord rendering quality and visual distinctiveness.

## 8.2 Evolution Status Embed

A dedicated `!evo status` command (or equivalent slash command) displays evolution status for
all cards on the roster (or a specific card):

```
[Team Name]'s Evolution Progress
================================
Mike Trout (All-Star) — T2 in progress
  Standard Batter Track
  Tier 2 milestones:
    [x] Win 8 games with card in lineup (8/8)
    [x] Hit 5 HR (5/5)
    [ ] Reach base 30 times (22/30)  <-- in progress
  Tier 2 unlocks: +1.0 budget (contact profile)
  Boost profile: auto-contact

Babe Ruth (HoF) — T1 complete, T2 in progress
  Legend Batter Track
  ...

Type !evo card <card_id> for detailed milestone breakdown.
Type !evo cosmetics <card_id> to browse premium visual upgrades.
```

## 8.3 Milestone Completion Notification

When a milestone is completed, the bot sends a notification in the team's designated channel:

```
[Seedling] Evolution milestone completed!
Mike Trout — T1, Milestone 2: "Record 15 Hits" COMPLETE
Tier 1 is now fully complete! Rating boost applied: +1.0 budget (contact profile)
[Mike Trout's card is now Tier 1 Evolved]
```

When a full tier is completed and the rarity upgrade fires:
```
[Crown] FULL EVOLUTION COMPLETE!
"[TeamName]'s Evolved Mike Trout" — Starter -> All-Star Upgrade Applied!
Rating boost: +1.0 budget capstone applied (4.0 total across all tiers).
This card is now permanently upgraded on your roster.
```

## 8.4 Premium Cosmetics Display

Premium cosmetics purchased via the currency store apply visual upgrades to the card's Discord
embed beyond the standard tier badge system:

For the full cosmetics catalog with pricing, see [10-economy.md § 10.3](10-economy.md#103-cosmetics-pricing).

| Cosmetic | Effect | Image Format |
|---|---|---|
| Gold Frame | Card embed styled with gold border accent | Static PNG |
| Diamond Frame | Diamond shimmer gradient border | Static PNG |
| Team Color Frame | Solid team-color border | Static PNG |
| Holographic Frame | Animated rainbow gradient border shimmer | **Animated APNG** |
| Dark Mode Theme | Charcoal header + dark column backgrounds | Static PNG |
| Midnight Theme | Deep navy/black column backgrounds | Static PNG |
| Prestige Badge | T4 badge upgraded from `:crown:` to `:crown::sparkles:` with custom label | Static PNG |
| Rarity Glow (Subtle) | Subtle pulsing glow matching rarity color | **Animated APNG** |
| Rarity Glow (Strong) | Intense pulsing glow matching rarity color | **Animated APNG** |
| Full Art Reveal | Custom card art background | Static PNG |

Cosmetics are purchased per `player_id` for a specific team. Since `player_id` maps 1:many with
`card_id`, purchasing a cosmetic for a player applies to all duplicate cards of that player on
the team — the variant hash is recomputed for all of them. Cosmetics are attached to the
`evolution_card_state` record (keyed by `player_id + team_id`), so duplicates always share the
same visual treatment.

When a card is traded, the new owner does **not** inherit purchased cosmetics — those remain
with the selling team's evolution state. The traded card's cosmetic display reverts to the
standard tier badge for the new owner.

This creates a flex incentive: players invest in cosmetics for cards they intend to keep and show
off. It also means a card with premium cosmetics is visually distinctive in gauntlet brackets
and trading discussions, providing social proof of investment.

## 8.5 Card Image Generation for Variants (Static)

When a new variant is created (tier completion or cosmetic purchase), the system:

1. Computes the variant hash from `(player_id, evolution_tier, cosmetics)`
2. Checks if a `battingcard` row with that hash already exists — if so, reuses it
3. If not, creates the variant's rating rows (base + evolution boosts applied)
4. Determines whether the variant requires animation (see § 8.6) or is static PNG
5. For **static variants**: renders the card via the existing Playwright pipeline, with cosmetic
   CSS injected into the HTML template (frame overlays, background gradients, evolution badge, etc.)
6. Uploads the PNG to S3 at `cards/cardset-{id}/player-{player_id}/v{variant}/battingcard.png`
7. Stores the S3 URL in `battingcard.image_url`
8. Updates `card.variant` on all card instances that should use this variant

The image format (PNG vs APNG) is determined by the file extension in the S3 URL stored in
`battingcard.image_url` — no separate format column is needed. The bot and Discord both handle
`.png` and `.apng` URLs identically.

The ~5 second render + upload cost is paid **once per variant creation**, not on every display.
Subsequent displays are instant — the bot reads `battingcard.image_url` and puts it in the embed
just like it reads `player.image` today.

**Cosmetic CSS injection** is handled by passing cosmetic parameters to the card rendering
endpoint. The HTML template conditionally applies CSS classes based on cosmetic flags:
- Frame overlays → CSS border/outline/box-shadow on the card container
- Background themes → replace hardcoded `#ACE6FF`/`#EAA49C` with themed colors
- Evolution badge → additional `<img>` element alongside the rarity badge
- Header gradient → custom gradient colors replacing the standard blue/red

See [14-risks.md § Open Question 9](14-risks.md) for CSS implementation approach discussion.

---

## 8.6 Animated Card Pipeline

### 8.6.1 Why APNG

Several cosmetic effects benefit from animation: holographic frame shimmer, rarity glow pulses,
evolution badge sparkle effects. The format choice matters:

| Format | Discord Embed | Browser `<img>` | Color Depth | File Size |
|---|---|---|---|---|
| **APNG** | Auto-plays, full support | Native, all browsers | Full 24-bit + alpha | ~1-4 MB for 8-12 frames |
| GIF | Auto-plays | Native | **256 colors** — visible banding on gradients | ~0.5-2 MB |
| Animated WebP | Works in most clients | Native, modern browsers | Full color | ~0.8-3 MB |
| MP4/WebM | **Not supported** in `set_image` | Requires `<video>` tag | Full color | Smallest |

**APNG wins** because it preserves the full PNG color depth our cards rely on (subtle gradients
in the blue/red columns, rarity badge details) while working natively in Discord embeds and
all modern browsers. GIF's 256-color limit would visibly degrade card quality. MP4/WebM cannot
be used in Discord embed images at all.

### 8.6.2 Which Cosmetics Trigger Animation

Not all cosmetics need animation. Only cosmetics that involve visible motion produce APNG output.
All others remain static PNG (smaller file, faster to render).

| Cosmetic | Animated? | Effect Description |
|---|---|---|
| Holographic Frame | Yes | Rainbow gradient shifts position across frames |
| Rarity Glow (Subtle) | Yes | Border glow opacity pulses 0.3 → 0.6 → 0.3 over ~2 seconds |
| Rarity Glow (Strong) | Yes | Border glow opacity pulses 0.5 → 1.0 → 0.5, larger spread |
| T4 Crown Badge sparkle | Yes | Small sparkle particles rotate around the crown icon |
| Gold Frame | No | Static gold border + subtle shadow |
| Diamond Frame | No | Static diamond-gradient border |
| Dark/Midnight themes | No | Static background color swap |
| Team Color Frame | No | Static solid border |

When a variant includes **any** animated cosmetic, the entire card is rendered as APNG.
When all cosmetics are static, the card stays PNG.

### 8.6.3 Render Pipeline

The animated card pipeline builds on the **persistent browser** optimization described in
[02-architecture.md](02-architecture.md#card-render-pipeline-optimization). Rather than launching
a new Chromium process, frames are captured using a page from the shared browser instance.

The pipeline uses **deterministic frame control** via a CSS custom property (`--anim-progress`)
rather than relying on CSS animation timing. This guarantees identical output across renders and
eliminates timing jitter.

```
1. Get page from persistent browser (shared instance, ~2ms)
2. Load HTML template with cosmetic CSS + animation custom properties
3. For each frame i in range(N):
   a. Set --anim-progress = i / N  (0.0 → 0.9)
   b. CSS calc() expressions update all animated properties from progress value
   c. page.screenshot() captures 1200x600 PNG frame buffer
4. Close page (~2ms)
5. Assemble frames into APNG using Python apng library
   - Frame delay: 166ms (6 FPS) for smooth loop without excessive file size
   - Loop count: 0 (infinite)
6. Optional: pngquant-compress each frame before assembly if over file size budget
7. Upload APNG to S3 with content-type image/apng
8. Store URL in battingcard.image_url (format inferred from .apng extension)
```

**Frame count targets by effect:**

| Effect | Frames | Duration | Rationale |
|---|---|---|---|
| Holographic shimmer | 12 | 2.0s | Full gradient cycle needs more frames for smoothness |
| Rarity glow pulse | 8 | 1.3s | Simple opacity fade needs fewer frames |
| Badge sparkle | 10 | 1.7s | Particle rotation at medium smoothness |

**Performance (with persistent browser):** Each screenshot takes ~0.4s with the persistent
browser (no launch overhead). Assembly adds ~1s.

| Variant Type | Frames | Capture Time | Assembly | Total |
|---|---|---|---|---|
| Static PNG | 1 | ~0.4s | — | ~0.6s |
| 8-frame APNG | 8 | ~3.2s | ~1.0s | ~4.5s |
| 12-frame APNG | 12 | ~4.8s | ~1.0s | ~6.0s |

Since animated cosmetics are premium purchases (not bulk operations), the volume is low —
estimated <50 animated variants per month at steady state.

### 8.6.4 Deterministic Frame Control

Animated card rendering uses a **custom CSS property** (`--anim-progress`) to drive all
animation state, rather than relying on CSS `animation` timing. This ensures:

- **Reproducible output:** Same inputs always produce identical frames
- **No timing jitter:** No dependency on Playwright's internal clock or screenshot timing
- **Simpler debugging:** Each frame's visual state is fully determined by a single `0.0 → 1.0` value

**Python frame capture loop:**

```python
async def capture_animated_frames(
    page: Page,
    html_content: str,
    frame_count: int = 10,
) -> list[bytes]:
    """Capture N deterministic animation frames from a card template.

    Each frame sets --anim-progress to i/frame_count, which CSS calc()
    expressions use to compute all animated property values.
    """
    await page.set_content(html_content)
    frames = []

    for i in range(frame_count):
        progress = i / frame_count  # 0.0, 0.1, 0.2, ... 0.9
        await page.evaluate(
            f"document.documentElement.style.setProperty('--anim-progress', '{progress}')"
        )
        frame = await page.screenshot(
            type="png",
            clip={"x": 0, "y": 0, "width": 1200, "height": 600},
        )
        frames.append(frame)

    return frames
```

**CSS using `--anim-progress` (replaces @keyframes):**

```css
:root {
  --anim-progress: 0;  /* set by Python per frame: 0.0 → 0.9 */
}

/* Holographic frame shimmer — gradient position driven by progress */
.frame-holographic {
  border: 4px solid transparent;
  background: linear-gradient(
    135deg,
    #ff0000, #ff8800, #ffff00, #00ff00,
    #0088ff, #8800ff, #ff0088, #ff0000
  ) border-box;
  background-size: 400% 400%;
  background-position: calc(var(--anim-progress) * 400%) 50%;
}

/* Rarity glow pulse — opacity follows a sine-like curve over progress
   Approximation: triangle wave via abs(progress - 0.5) * 2
   At progress=0: opacity=0.3, progress=0.5: opacity=0.8, progress=1: opacity=0.3 */
.glow-subtle {
  --glow-intensity: calc(0.3 + 0.5 * (1 - abs(var(--anim-progress) - 0.5) * 2));
  box-shadow: 0 0 calc(8px + 12px * var(--glow-intensity)) calc(2px + 4px * var(--glow-intensity)) var(--rarity-color);
  opacity: var(--glow-intensity);
}

.glow-strong {
  --glow-intensity: calc(0.5 + 0.5 * (1 - abs(var(--anim-progress) - 0.5) * 2));
  box-shadow: 0 0 calc(12px + 20px * var(--glow-intensity)) calc(4px + 8px * var(--glow-intensity)) var(--rarity-color);
  opacity: var(--glow-intensity);
}

/* T4 Crown badge sparkle — rotation driven by progress */
.badge-sparkle {
  transform: rotate(calc(var(--anim-progress) * 360deg));
}
```

**Why not CSS @keyframes?** CSS animation timing in headless Chromium depends on internal clock
ticks between screenshots. Two renders of the same card could produce slightly different frames
if the timing drifts. The `--anim-progress` approach makes each frame's visual state a pure
function of its index — deterministic and reproducible.

### 8.6.5 APNG Assembly

Python assembly using the `apng` library:

```python
from apng import APNG, PNG

def assemble_apng(frame_buffers: list[bytes], delay_ms: int = 166) -> bytes:
    """Assemble PNG frame buffers into an APNG.

    Args:
        frame_buffers: List of PNG image bytes (one per frame).
        delay_ms: Delay between frames in milliseconds.

    Returns:
        APNG file bytes ready for S3 upload.
    """
    apng = APNG()
    for buf in frame_buffers:
        png = PNG.from_bytes(buf)
        apng.append(png, delay=delay_ms, delay_den=1000)
    output = io.BytesIO()
    apng.save(output)
    return output.getvalue()
```

**Dependencies:** `pip install apng` (pure Python, no native deps). The `apng` library handles
APNG chunk encoding per the PNG spec extension. No need for external tools like `apngasm`.

### 8.6.6 S3 Storage and Content-Type

Animated cards use `.apng` extension and `image/apng` content type:

```python
s3_key = f"cards/cardset-{cardset_id:03d}/player-{player_id}/v{variant}/battingcard.apng"

await s3_client.put_object(
    Bucket=bucket,
    Key=s3_key,
    Body=apng_bytes,
    ContentType="image/apng",
    CacheControl="public, max-age=31536000",  # immutable variant images
)
```

Discord and browsers handle `image/apng` identically to `image/png` for display purposes — the
APNG spec is a backward-compatible extension of PNG. A client that doesn't support APNG will
display the first frame as a static PNG, which is an acceptable graceful degradation.

### 8.6.7 Bot Display Logic Update

The bot's image resolution needs a minor update to handle both formats:

```python
if card.variant != 0:
    bc = BattingCard.get(player_id=card.player_id, variant=card.variant)
    image_url = bc.image_url  # could be .png or .apng — Discord handles both
else:
    image_url = card.player.image  # always static PNG

embed.set_image(url=image_url)  # Discord auto-plays APNG in embeds
```

No format-specific logic needed in the bot — Discord's embed renderer handles APNG auto-play
transparently. The image format is inferred from the URL extension (`.png` vs `.apng`) when
the render pipeline needs to decide whether to re-render as animated.

### 8.6.8 File Size Budget

Target file sizes to keep Discord embeds responsive:

| Type | Target | Max |
|---|---|---|
| Static PNG | ~150-250 KB | 500 KB |
| Animated APNG (8 frames) | ~1.0-2.0 MB | 4 MB |
| Animated APNG (12 frames) | ~1.5-3.0 MB | 5 MB |

Discord's embed image limit is 25 MB, so we have significant headroom. However, large images
slow down embed rendering on mobile clients. The targets above keep load times under 1 second
on typical connections.

**Optimization options if sizes run high:**
- Reduce frame count (8 instead of 12 — less smooth but much smaller)
- Use `pngquant` to lossy-compress each frame before assembly (can cut 50-70% with minimal
  visual impact on card images)
- Only animate the border/glow region and keep the card body static (composite optimization)

### 8.6.9 Animated Card Cosmetics Pricing

Animated cosmetics carry a premium over static equivalents, reflecting both the visual impact
and the higher render cost:

| Cosmetic | Static Equivalent | Animated Price | Premium Over Static |
|---|---|---|---|
| Holographic Frame | Gold Frame (800₼) | 2,000₼ | +150% |
| Rarity Glow (Subtle) | — (no static equiv) | 1,200₼ | — |
| Rarity Glow (Strong) | Rarity Glow Subtle (1,200₼) | 1,800₼ | +50% |

The higher price point makes animated cards a genuine flex — visible rarity in gauntlet brackets
and trading negotiations.
