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

403 lines
17 KiB
Markdown

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