claude-home/paper-dynasty/card-evolution-prd/08-display.md
Cal Corum aafe527d51
All checks were successful
Reindex Knowledge Base / reindex (push) Successful in 5s
docs: add Major Domo and Paper Dynasty release notes and card evolution PRD
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:29:18 -05:00

17 KiB

8. Discord Display, Visual Identity, and Animated Cards

< Back to Index | Next: Integrations >


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.

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 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. 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:

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):

: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:

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:

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:

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.