Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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:
- Computes the variant hash from
(player_id, evolution_tier, cosmetics) - Checks if a
battingcardrow with that hash already exists — if so, reuses it - If not, creates the variant's rating rows (base + evolution boosts applied)
- Determines whether the variant requires animation (see § 8.6) or is static PNG
- 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.)
- Uploads the PNG to S3 at
cards/cardset-{id}/player-{player_id}/v{variant}/battingcard.png - Stores the S3 URL in
battingcard.image_url - Updates
card.varianton 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/#EAA49Cwith 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.0value
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
pngquantto 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.