| name | remotion-animations |
| description | Remotion animation integration for FlowState's Cyberflow RPG. Encodes Remotion setup in Vue 3 projects, key animation sequences (level-up celebration, boss defeat, mission complete), composition patterns, asset pipeline for cyberpunk SVGs/graphics, performance optimization with lazy-loading, and CSS-only fallback patterns. Use when building cinematic game animations. |
Remotion Animation Integration for FlowState Cyberflow RPG
1. Remotion Overview & Vue Integration Strategy
Remotion is a React framework for programmatic video and animation. Since FlowState is Vue 3, a bridge strategy is required.
Option A: Iframe Bridge (Recommended for isolation)
- Build Remotion compositions as a separate React mini-app
- Embed via iframe in Vue components
- Communicate via postMessage API
- Pros: Full isolation, no React in Vue bundle, easy to disable
- Cons: Slight overhead, iframe communication latency
Option B: React-in-Vue Bridge
- Use
@veact/core or manual React mounting in Vue
- Mount Remotion
<Player> component inside a Vue wrapper
- Pros: Direct control, no iframe
- Cons: React + ReactDOM added to bundle (~45KB gzipped)
Option C: Remotion Lambda / Pre-rendered Videos
- Pre-render animations as MP4/WebM using Remotion CLI
- Serve as video files, play with HTML5
<video> tag
- Pros: Zero runtime dependency, best performance
- Cons: No dynamic data in animations, larger file sizes
Recommendation for FlowState
Start with Option C (pre-rendered) for static animations (boss defeat explosion, generic level-up) and Option B (React-in-Vue bridge) for dynamic ones (showing actual level number, XP amount, boss name). Research the latest Remotion docs before implementing to verify the best current approach.
2. Remotion Project Setup
Installation
npm install remotion @remotion/cli @remotion/player react react-dom
npm install @types/react @types/react-dom
Project Structure
src/
remotion/ # Remotion compositions (React)
assets/ # SVG sprites, cyberpunk backgrounds, particles
compositions/
LevelUpAnimation.tsx
BossDefeatAnimation.tsx
MissionCompleteAnimation.tsx
XpBurstAnimation.tsx
index.ts # Export all compositions
Root.tsx # Remotion root (for CLI rendering)
components/gamification/cyber/
RemotionBridge.vue # Vue wrapper for Remotion Player
CyberLevelUp.vue # Uses RemotionBridge
CyberBossDefeat.vue # Uses RemotionBridge
CyberMissionComplete.vue # Uses RemotionBridge
composables/
useGameAnimations.ts # Animation trigger composable
Vite Configuration for React JSX
import vue from '@vitejs/plugin-vue'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
vue(),
react({ include: /remotion\/.*\.tsx$/ }),
]
})
Important: Verify that @vitejs/plugin-react co-exists with @vitejs/plugin-vue in the same Vite config. If conflicts arise, use @vitejs/plugin-react with explicit include patterns to restrict it to the src/remotion/ directory only.
Remotion Root (for CLI rendering)
import { Composition } from 'remotion'
import { LevelUpAnimation } from './compositions/LevelUpAnimation'
import { BossDefeatAnimation } from './compositions/BossDefeatAnimation'
import { MissionCompleteAnimation } from './compositions/MissionCompleteAnimation'
import { XpBurstAnimation } from './compositions/XpBurstAnimation'
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="LevelUpAnimation"
component={LevelUpAnimation}
durationInFrames={90}
fps={30}
width={800}
height={400}
defaultProps={{
oldLevel: 4,
newLevel: 5,
xpEarned: 500,
tierColor: '#FFD700',
}}
/>
<Composition
id="BossDefeatAnimation"
component={BossDefeatAnimation}
durationInFrames={150}
fps={30}
width={800}
height={400}
defaultProps={{
bossName: 'ENTROPY WARDEN',
damageDealt: 1500,
totalHp: 1500,
rewards: { xp: 1000, items: ['Neural Enhancer', 'Code Fragment'] },
ariaMessage: 'Boss defeated. Rewards collected.',
}}
/>
<Composition
id="MissionCompleteAnimation"
component={MissionCompleteAnimation}
durationInFrames={60}
fps={30}
width={800}
height={400}
defaultProps={{
missionTitle: 'Clear 5 Tasks',
xpReward: 200,
corruptionDecrease: 10,
ariaMessage: 'Mission complete. XP awarded.',
}}
/>
<Composition
id="XpBurstAnimation"
component={XpBurstAnimation}
durationInFrames={30}
fps={30}
width={200}
height={100}
defaultProps={{
xpAmount: 50,
}}
/>
</>
)
}
Index Exports
export { LevelUpAnimation } from './compositions/LevelUpAnimation'
export { BossDefeatAnimation } from './compositions/BossDefeatAnimation'
export { MissionCompleteAnimation } from './compositions/MissionCompleteAnimation'
export { XpBurstAnimation } from './compositions/XpBurstAnimation'
3. RemotionBridge.vue -- The Vue-to-React Bridge
<template>
<div
ref="containerRef"
class="remotion-container"
:class="{ active: isPlaying }"
role="img"
:aria-label="ariaLabel"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
const props = withDefaults(defineProps<{
compositionId: string
inputProps?: Record<string, any>
durationInFrames: number
fps?: number
width?: number
height?: number
ariaLabel?: string
onComplete?: () => void
}>(), {
fps: 30,
width: 800,
height: 400,
ariaLabel: 'Animation',
inputProps: () => ({}),
})
const emit = defineEmits<{
complete: []
error: [error: Error]
}>()
const containerRef = ref<HTMLElement>()
const isPlaying = ref(false)
let reactRoot: any = null
async function mountPlayer() {
if (!containerRef.value) return
try {
// Dynamically import React and Remotion (code-split)
const [React, ReactDOM, { Player }] = await Promise.all([
import('react'),
import('react-dom/client'),
import('@remotion/player'),
])
const compositions = await import('@/remotion')
const Component = (compositions as Record<string, React.FC<any>>)[props.compositionId]
if (!Component) {
console.warn(`[RemotionBridge] Composition "${props.compositionId}" not found`)
emit('error', new Error(`Composition "${props.compositionId}" not found`))
return
}
reactRoot = ReactDOM.createRoot(containerRef.value)
// Track completion via frame counting
const completionTimeout = setTimeout(() => {
isPlaying.value = false
emit('complete')
props.onComplete?.()
}, (props.durationInFrames / props.fps) * 1000)
reactRoot.render(
React.createElement(Player, {
component: Component,
inputProps: props.inputProps,
durationInFrames: props.durationInFrames,
fps: props.fps,
compositionWidth: props.width,
compositionHeight: props.height,
autoPlay: true,
loop: false,
controls: false,
style: { width: '100%', height: '100%' },
renderLoading: () => null,
})
)
isPlaying.value = true
// Store timeout for cleanup
;(containerRef.value as any).__completionTimeout = completionTimeout
} catch (err) {
console.error('[RemotionBridge] Failed to mount Remotion player:', err)
emit('error', err instanceof Error ? err : new Error(String(err)))
}
}
function unmountPlayer() {
if (reactRoot) {
reactRoot.unmount()
reactRoot = null
}
if (containerRef.value) {
const timeout = (containerRef.value as any).__completionTimeout
if (timeout) clearTimeout(timeout)
}
isPlaying.value = false
}
onMounted(mountPlayer)
onBeforeUnmount(unmountPlayer)
// Re-mount if composition changes
watch(() => props.compositionId, () => {
unmountPlayer()
mountPlayer()
})
</script>
<style scoped>
.remotion-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.remotion-container.active {
pointer-events: auto;
}
</style>
4. Key Animation Compositions
TypeScript Interfaces
export interface LevelUpProps {
oldLevel: number
newLevel: number
xpEarned: number
tierColor: string
}
export interface BossDefeatProps {
bossName: string
damageDealt: number
totalHp: number
rewards: { xp: number; items: string[] }
ariaMessage: string
}
export interface MissionCompleteProps {
missionTitle: string
xpReward: number
corruptionDecrease: number
ariaMessage: string
}
export interface XpBurstProps {
xpAmount: number
}
A. Level-Up Celebration (LevelUpAnimation.tsx)
Duration: 90 frames (3 seconds at 30fps)
Sequence:
- Frame 0-10: Screen dims, focus zoom
- Frame 10-30: "LEVEL UP" text scales in with spring physics, neon glow intensifies
- Frame 20-40: Level number morphs (old -> new) with digital counter effect
- Frame 30-60: Particle burst radiates outward (12 particles in circle)
- Frame 40-70: Stats summary fades in ("+500 XP", new perks)
- Frame 60-90: Everything fades out gracefully
import React from 'react'
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
interpolate,
spring,
useVideoConfig,
} from 'remotion'
import type { LevelUpProps } from '../types'
const Particle: React.FC<{
angle: number
delay: number
color: string
}> = ({ angle, delay, color }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const progress = spring({
frame: frame - delay,
fps,
config: { damping: 20, stiffness: 80, mass: 0.5 },
})
const distance = interpolate(progress, [0, 1], [0, 300])
const opacity = interpolate(frame - delay, [0, 15, 30], [0, 1, 0], {
extrapolateRight: 'clamp',
})
const x = Math.cos((angle * Math.PI) / 180) * distance
const y = Math.sin((angle * Math.PI) / 180) * distance
return (
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: 12,
height: 12,
borderRadius: '50%',
background: color,
boxShadow: `0 0 20px ${color}, 0 0 40px ${color}`,
transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`,
opacity,
}}
/>
)
}
export const LevelUpAnimation: React.FC<LevelUpProps> = ({
oldLevel,
newLevel,
xpEarned,
tierColor,
}) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const bgOpacity = interpolate(frame, [0, 10, 60, 90], [0, 0.7, 0.7, 0], {
extrapolateRight: 'clamp',
})
const titleScale = spring({
frame: frame - 10,
fps,
config: { damping: 10, stiffness: 100, mass: 0.8 },
})
const titleOpacity = interpolate(frame, [10, 15, 60, 90], [0, 1, 1, 0], {
extrapolateRight: 'clamp',
extrapolateLeft: 'clamp',
})
const levelProgress = interpolate(frame, [20, 40], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const displayLevel = Math.round(
interpolate(levelProgress, [0, 1], [oldLevel, newLevel])
)
const statsOpacity = interpolate(frame, [40, 50, 60, 90], [0, 1, 1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const particles = Array.from({ length: 12 }, (_, i) => ({
angle: (360 / 12) * i,
delay: 30 + i * 2,
color: tierColor,
}))
return (
<AbsoluteFill>
{/* Background overlay */}
<AbsoluteFill
style={{
backgroundColor: `rgba(0, 0, 0, ${bgOpacity})`,
}}
/>
{/* Particle burst (frame 30-60) */}
<Sequence from={30} durationInFrames={40}>
{particles.map((p, i) => (
<Particle key={i} angle={p.angle} delay={0} color={p.color} />
))}
</Sequence>
{/* LEVEL UP text */}
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
fontFamily: 'monospace',
fontSize: 64,
fontWeight: 'bold',
color: tierColor,
textShadow: `0 0 20px ${tierColor}, 0 0 40px ${tierColor}, 0 0 80px ${tierColor}`,
transform: `scale(${titleScale})`,
opacity: titleOpacity,
letterSpacing: '0.2em',
}}
>
LEVEL UP
</div>
{/* Level number */}
<div
style={{
fontFamily: 'monospace',
fontSize: 96,
fontWeight: 'bold',
color: '#ffffff',
textShadow: `0 0 30px ${tierColor}`,
opacity: titleOpacity,
marginTop: 10,
}}
>
{displayLevel}
</div>
{/* Stats summary */}
<div
style={{
fontFamily: 'monospace',
fontSize: 24,
color: tierColor,
opacity: statsOpacity,
marginTop: 20,
textAlign: 'center',
}}
>
+{xpEarned} XP
</div>
</AbsoluteFill>
</AbsoluteFill>
)
}
B. Boss Defeat (BossDefeatAnimation.tsx)
Duration: 150 frames (5 seconds at 30fps)
Sequence:
- Frame 0-20: Boss name glitches intensely (clip-path flicker)
- Frame 20-50: HP bar drains to 0 with red flash
- Frame 50-80: "TERMINATED" text slams in with screen shake
- Frame 80-100: Explosion particle effect (radial burst)
- Frame 100-130: Loot reward cards flip in (staggered)
- Frame 130-150: Victory summary + ARIA message
import React from 'react'
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
interpolate,
spring,
useVideoConfig,
} from 'remotion'
import type { BossDefeatProps } from '../types'
const GlitchText: React.FC<{ text: string; intensity: number }> = ({
text,
intensity,
}) => {
const frame = useCurrentFrame()
const glitchOffset = Math.sin(frame * 13.7) * intensity * 5
return (
<div style={{ position: 'relative' }}>
{/* Red channel offset */}
<div
style={{
position: 'absolute',
color: 'rgba(255, 0, 0, 0.7)',
transform: `translate(${glitchOffset}px, ${-glitchOffset * 0.5}px)`,
clipPath:
intensity > 0.5
? `inset(${Math.random() * 30}% 0 ${Math.random() * 30}% 0)`
: 'none',
}}
>
{text}
</div>
{/* Cyan channel offset */}
<div
style={{
position: 'absolute',
color: 'rgba(0, 255, 255, 0.7)',
transform: `translate(${-glitchOffset}px, ${glitchOffset * 0.3}px)`,
}}
>
{text}
</div>
{/* Main text */}
<div style={{ position: 'relative', color: '#ffffff' }}>{text}</div>
</div>
)
}
const LootCard: React.FC<{
item: string
index: number
totalCards: number
}> = ({ item, index, totalCards }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const flipProgress = spring({
frame: frame - index * 8,
fps,
config: { damping: 12, stiffness: 80 },
})
const rotateY = interpolate(flipProgress, [0, 1], [90, 0])
const opacity = interpolate(flipProgress, [0, 0.3, 1], [0, 1, 1])
const xOffset = (index - (totalCards - 1) / 2) * 140
return (
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: `translate(${xOffset}px, -50%) perspective(600px) rotateY(${rotateY}deg)`,
opacity,
width: 120,
height: 160,
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
border: '2px solid #00ffff',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 10,
boxShadow: '0 0 20px rgba(0, 255, 255, 0.3)',
}}
>
<span
style={{
fontFamily: 'monospace',
fontSize: 14,
color: '#00ffff',
textAlign: 'center',
}}
>
{item}
</span>
</div>
)
}
export const BossDefeatAnimation: React.FC<BossDefeatProps> = ({
bossName,
damageDealt,
totalHp,
rewards,
ariaMessage,
}) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const bgOpacity = interpolate(frame, [0, 10, 130, 150], [0, 0.85, 0.85, 0], {
extrapolateRight: 'clamp',
})
const glitchIntensity = interpolate(frame, [0, 10, 20], [0.2, 1, 0], {
extrapolateRight: 'clamp',
})
const hpPercent = interpolate(frame, [20, 50], [100, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const redFlash = interpolate(frame, [45, 50, 55], [0, 0.5, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const terminatedScale = spring({
frame: frame - 50,
fps,
config: { damping: 8, stiffness: 200, mass: 1.2 },
})
const shakeX =
frame >= 50 && frame <= 70 ? Math.sin(frame * 23) * (70 - frame) * 0.5 : 0
const shakeY =
frame >= 50 && frame <= 70 ? Math.cos(frame * 17) * (70 - frame) * 0.3 : 0
const victoryOpacity = interpolate(frame, [130, 140, 145, 150], [0, 1, 1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const bossNameOpacity = interpolate(frame, [0, 5, 18, 22], [0, 1, 1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
return (
<AbsoluteFill
style={{ transform: `translate(${shakeX}px, ${shakeY}px)` }}
>
{/* Dark background */}
<AbsoluteFill
style={{ backgroundColor: `rgba(10, 0, 0, ${bgOpacity})` }}
/>
{/* Red flash overlay */}
<AbsoluteFill
style={{ backgroundColor: `rgba(255, 0, 0, ${redFlash})` }}
/>
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Boss name with glitch (frame 0-20) */}
<Sequence from={0} durationInFrames={25}>
<AbsoluteFill
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: bossNameOpacity,
}}
>
<div style={{ fontFamily: 'monospace', fontSize: 48, fontWeight: 'bold' }}>
<GlitchText text={bossName} intensity={glitchIntensity} />
</div>
</AbsoluteFill>
</Sequence>
{/* HP Bar (frame 20-55) */}
<Sequence from={20} durationInFrames={35}>
<AbsoluteFill
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: 400,
height: 24,
background: 'rgba(255,255,255,0.1)',
borderRadius: 12,
overflow: 'hidden',
border: '1px solid rgba(255,0,0,0.5)',
}}
>
<div
style={{
width: `${hpPercent}%`,
height: '100%',
background: 'linear-gradient(90deg, #ff0000, #ff4444)',
borderRadius: 12,
transition: 'none',
boxShadow: '0 0 10px rgba(255,0,0,0.5)',
}}
/>
</div>
</AbsoluteFill>
</Sequence>
{/* TERMINATED text (frame 50-80) */}
<Sequence from={50} durationInFrames={80}>
<AbsoluteFill
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
fontFamily: 'monospace',
fontSize: 80,
fontWeight: 'bold',
color: '#ff0040',
textShadow:
'0 0 30px #ff0040, 0 0 60px #ff0040, 0 0 100px #ff0040',
transform: `scale(${terminatedScale})`,
letterSpacing: '0.15em',
}}
>
TERMINATED
</div>
</AbsoluteFill>
</Sequence>
{/* Loot cards (frame 100-130) */}
<Sequence from={100} durationInFrames={40}>
<AbsoluteFill>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<LootCard item={`+${rewards.xp} XP`} index={0} totalCards={rewards.items.length + 1} />
{rewards.items.map((item, i) => (
<LootCard
key={item}
item={item}
index={i + 1}
totalCards={rewards.items.length + 1}
/>
))}
</div>
</AbsoluteFill>
</Sequence>
{/* Victory summary (frame 130-150) */}
<div
style={{
position: 'absolute',
bottom: 60,
fontFamily: 'monospace',
fontSize: 18,
color: '#00ffff',
opacity: victoryOpacity,
textAlign: 'center',
}}
>
{ariaMessage}
</div>
</AbsoluteFill>
</AbsoluteFill>
)
}
C. Mission Complete (MissionCompleteAnimation.tsx)
Duration: 60 frames (2 seconds at 30fps)
Sequence:
- Frame 0-10: Mission card flips to "COMPLETE" side
- Frame 10-30: Progress bar fills to 100% with glow
- Frame 20-40: "+XP" counter rolls up
- Frame 30-50: Corruption meter decreases (if applicable)
- Frame 40-60: ARIA approval message types in
import React from 'react'
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
interpolate,
spring,
useVideoConfig,
} from 'remotion'
import type { MissionCompleteProps } from '../types'
export const MissionCompleteAnimation: React.FC<MissionCompleteProps> = ({
missionTitle,
xpReward,
corruptionDecrease,
ariaMessage,
}) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const flipProgress = spring({
frame,
fps,
config: { damping: 12, stiffness: 120 },
})
const rotateY = interpolate(flipProgress, [0, 1], [180, 0])
const progressFill = interpolate(frame, [10, 30], [0, 100], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const xpDisplay = Math.round(
interpolate(frame, [20, 40], [0, xpReward], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
)
const corruptionOpacity = interpolate(frame, [30, 35, 45, 50], [0, 1, 1, 0.8], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const messageChars = Math.round(
interpolate(frame, [40, 58], [0, ariaMessage.length], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
)
const overallOpacity = interpolate(frame, [55, 60], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
const bgOpacity = interpolate(frame, [0, 5, 55, 60], [0, 0.75, 0.75, 0], {
extrapolateRight: 'clamp',
})
return (
<AbsoluteFill style={{ opacity: overallOpacity }}>
<AbsoluteFill
style={{ backgroundColor: `rgba(0, 10, 20, ${bgOpacity})` }}
/>
<AbsoluteFill
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
{/* Mission card */}
<div
style={{
width: 400,
padding: '24px 32px',
background: 'linear-gradient(135deg, #0a1628 0%, #1a2744 100%)',
border: '2px solid #00ffaa',
borderRadius: 12,
transform: `perspective(600px) rotateY(${rotateY}deg)`,
boxShadow: '0 0 30px rgba(0, 255, 170, 0.2)',
}}
>
{/* Title */}
<div
style={{
fontFamily: 'monospace',
fontSize: 14,
color: '#00ffaa',
textTransform: 'uppercase',
letterSpacing: '0.15em',
marginBottom: 8,
}}
>
MISSION COMPLETE
</div>
<div
style={{
fontFamily: 'monospace',
fontSize: 22,
color: '#ffffff',
fontWeight: 'bold',
marginBottom: 16,
}}
>
{missionTitle}
</div>
{/* Progress bar */}
<div
style={{
width: '100%',
height: 8,
background: 'rgba(255,255,255,0.1)',
borderRadius: 4,
overflow: 'hidden',
marginBottom: 16,
}}
>
<div
style={{
width: `${progressFill}%`,
height: '100%',
background: 'linear-gradient(90deg, #00ffaa, #00ff88)',
borderRadius: 4,
boxShadow: progressFill >= 100 ? '0 0 15px #00ffaa' : 'none',
}}
/>
</div>
{/* XP reward */}
<div
style={{
fontFamily: 'monospace',
fontSize: 28,
color: '#FFD700',
textShadow: '0 0 15px rgba(255, 215, 0, 0.5)',
textAlign: 'center',
}}
>
+{xpDisplay} XP
</div>
{/* Corruption decrease */}
{corruptionDecrease > 0 && (
<div
style={{
fontFamily: 'monospace',
fontSize: 18,
color: '#00ff88',
textAlign: 'center',
marginTop: 8,
opacity: corruptionOpacity,
}}
>
Corruption -{corruptionDecrease}%
</div>
)}
</div>
{/* ARIA message typewriter */}
<div
style={{
fontFamily: 'monospace',
fontSize: 16,
color: '#00ccff',
maxWidth: 400,
textAlign: 'center',
minHeight: 24,
}}
>
{ariaMessage.slice(0, messageChars)}
{messageChars < ariaMessage.length && (
<span style={{ opacity: frame % 6 < 3 ? 1 : 0 }}>_</span>
)}
</div>
</AbsoluteFill>
</AbsoluteFill>
)
}
D. XP Burst (micro-animation, for moderate intensity level)
Duration: 30 frames (1 second)
- Simple: "+XP" rises and fades with gold glow
- Used inline, not full-screen
import React from 'react'
import { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig } from 'remotion'
import type { XpBurstProps } from '../types'
export const XpBurstAnimation: React.FC<XpBurstProps> = ({ xpAmount }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const scale = spring({
frame,
fps,
config: { damping: 10, stiffness: 150 },
})
const yOffset = interpolate(frame, [0, 30], [0, -40])
const opacity = interpolate(frame, [0, 5, 20, 30], [0, 1, 1, 0], {
extrapolateRight: 'clamp',
})
return (
<AbsoluteFill
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
fontFamily: 'monospace',
fontSize: 32,
fontWeight: 'bold',
color: '#FFD700',
textShadow: '0 0 15px rgba(255, 215, 0, 0.8), 0 0 30px rgba(255, 215, 0, 0.4)',
transform: `scale(${scale}) translateY(${yOffset}px)`,
opacity,
}}
>
+{xpAmount} XP
</div>
</AbsoluteFill>
)
}
5. Triggering Animations from Vue
import { ref, type Ref } from 'vue'
import { useSettingsStore } from '@/stores/settings'
interface AnimationState {
id: string
props: Record<string, any>
durationInFrames: number
}
export function useGameAnimations() {
const settings = useSettingsStore()
const showAnimation: Ref<boolean> = ref(false)
const currentAnimation: Ref<AnimationState | null> = ref(null)
function getTierColor(level: number): string {
if (level >= 50) return '#E5E4E2'
if (level >= 30) return '#FFD700'
if (level >= 15) return '#C0C0C0'
return '#CD7F32'
}
function triggerLevelUp(oldLevel: number, newLevel: number, xpEarned: number) {
if (settings.gamificationIntensity !== 'intense') return
currentAnimation.value = {
id: 'LevelUpAnimation',
props: {
oldLevel,
newLevel,
xpEarned,
tierColor: getTierColor(newLevel),
},
durationInFrames: 90,
}
showAnimation.value = true
}
function triggerBossDefeat(
bossName: string,
totalHp: number,
rewards: { xp: number; items: string[] }
) {
if (settings.gamificationIntensity !== 'intense') return
currentAnimation.value = {
id: 'BossDefeatAnimation',
props: {
bossName,
damageDealt: totalHp,
totalHp,
rewards,
ariaMessage: `${bossName} has been defeated. ${rewards.xp} XP earned.`,
},
durationInFrames: 150,
}
showAnimation.value = true
}
function triggerMissionComplete(
missionTitle: string,
xpReward: number,
corruptionDecrease: number = 0
) {
if (settings.gamificationIntensity !== 'intense') return
currentAnimation.value = {
id: 'MissionCompleteAnimation',
props: {
missionTitle,
xpReward,
corruptionDecrease,
ariaMessage: `Mission "${missionTitle}" accomplished. ${xpReward} XP awarded.`,
},
durationInFrames: 60,
}
showAnimation.value = true
}
function triggerXpBurst(xpAmount: number) {
const intensity = settings.gamificationIntensity
if (intensity === 'minimal') return
currentAnimation.value = {
id: 'XpBurstAnimation',
props: { xpAmount },
durationInFrames: 30,
}
showAnimation.value = true
}
function onAnimationComplete() {
showAnimation.value = false
currentAnimation.value = null
}
return {
showAnimation,
currentAnimation,
triggerLevelUp,
triggerBossDefeat,
triggerMissionComplete,
triggerXpBurst,
onAnimationComplete,
}
}
Usage in a Vue component
<template>
<div>
<!-- Regular app content -->
<slot />
<!-- Remotion animation overlay -->
<Teleport to="body">
<div
v-if="showAnimation && currentAnimation"
class="animation-overlay"
@click="onAnimationComplete"
>
<RemotionBridge
:composition-id="currentAnimation.id"
:input-props="currentAnimation.props"
:duration-in-frames="currentAnimation.durationInFrames"
@complete="onAnimationComplete"
@error="onAnimationError"
/>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { useGameAnimations } from '@/composables/useGameAnimations'
import RemotionBridge from '@/components/gamification/cyber/RemotionBridge.vue'
const { showAnimation, currentAnimation, onAnimationComplete } = useGameAnimations()
function onAnimationError(error: Error) {
console.warn('[GameAnimations] Falling back to CSS animation:', error.message)
onAnimationComplete()
// Could trigger CSS fallback here
}
</script>
<style scoped>
.animation-overlay {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer; /* Click to dismiss */
}
</style>
6. CSS-Only Fallbacks
For every Remotion animation, provide a pure CSS alternative. These are used when:
- Remotion fails to load (network error, bundle issue)
- Gamification intensity is set to
moderate (partial animations)
- React/Remotion bundle is not yet loaded (first trigger)
Level-Up CSS Fallback
.level-up-css {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: level-up-flash 2s ease-out forwards;
}
.level-up-css__text {
font-family: var(--font-cyber-title, 'Orbitron', monospace);
font-size: 4rem;
color: var(--cf-gold, #FFD700);
text-shadow:
0 0 20px var(--cf-gold, #FFD700),
0 0 40px var(--cf-gold, #FFD700),
0 0 80px var(--cf-gold, #FFD700);
animation: level-up-scale 1s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
.level-up-css__level {
font-family: var(--font-cyber-title, 'Orbitron', monospace);
font-size: 5rem;
color: #ffffff;
text-shadow: 0 0 30px var(--cf-gold, #FFD700);
animation: level-up-scale 1s cubic-bezier(0.34, 1.56, 0.64, 1) 0.3s both;
}
.level-up-css__xp {
font-family: monospace;
font-size: 1.5rem;
color: var(--cf-gold, #FFD700);
animation: level-up-fade-in 0.5s ease-out 1s both;
}
@keyframes level-up-flash {
0% { background: rgba(255, 215, 0, 0.3); }
30% { background: rgba(255, 215, 0, 0.15); }
100% { background: transparent; }
}
@keyframes level-up-scale {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes level-up-fade-in {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
Boss Defeat CSS Fallback
.boss-defeat-css {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: boss-defeat-bg 4s ease-out forwards;
}
.boss-defeat-css__name {
font-family: var(--font-cyber-title, 'Orbitron', monospace);
font-size: 2.5rem;
color: #ff0040;
animation: boss-glitch 0.8s steps(3) forwards;
}
.boss-defeat-css__terminated {
font-family: var(--font-cyber-title, 'Orbitron', monospace);
font-size: 4.5rem;
font-weight: bold;
color: #ff0040;
text-shadow:
0 0 30px #ff0040,
0 0 60px #ff0040,
0 0 100px #ff0040;
letter-spacing: 0.15em;
animation: boss-slam 0.6s cubic-bezier(0.22, 1, 0.36, 1) 1s both;
}
.boss-defeat-css__rewards {
font-family: monospace;
font-size: 1.2rem;
color: #00ffff;
animation: level-up-fade-in 0.5s ease-out 2s both;
}
@keyframes boss-defeat-bg {
0% { background: rgba(10, 0, 0, 0); }
15% { background: rgba(10, 0, 0, 0.85); }
85% { background: rgba(10, 0, 0, 0.85); }
100% { background: transparent; }
}
@keyframes boss-glitch {
0% { transform: translate(0); clip-path: inset(0 0 0 0); }
25% { transform: translate(-5px, 3px); clip-path: inset(20% 0 40% 0); }
50% { transform: translate(5px, -3px); clip-path: inset(60% 0 10% 0); }
75% { transform: translate(-3px, -2px); clip-path: inset(30% 0 30% 0); }
100% { transform: translate(0); clip-path: inset(0 0 0 0); opacity: 0; }
}
@keyframes boss-slam {
0% { transform: scale(3); opacity: 0; }
50% { transform: scale(0.9); opacity: 1; }
70% { transform: scale(1.05); }
100% { transform: scale(1); opacity: 1; }
}
.boss-defeat-css.shaking {
animation: screen-shake 0.3s ease-in-out 1.2s 3;
}
@keyframes screen-shake {
0%, 100% { transform: translate(0); }
25% { transform: translate(-5px, 3px); }
50% { transform: translate(5px, -2px); }
75% { transform: translate(-3px, 4px); }
}
Mission Complete CSS Fallback
.mission-complete-css {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: mission-bg 2s ease-out forwards;
}
.mission-complete-css__card {
width: 400px;
max-width: 90vw;
padding: 24px 32px;
background: linear-gradient(135deg, #0a1628 0%, #1a2744 100%);
border: 2px solid #00ffaa;
border-radius: 12px;
box-shadow: 0 0 30px rgba(0, 255, 170, 0.2);
animation: mission-flip 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
transform-style: preserve-3d;
}
.mission-complete-css__label {
font-family: monospace;
font-size: 0.875rem;
color: #00ffaa;
text-transform: uppercase;
letter-spacing: 0.15em;
margin-bottom: 8px;
}
.mission-complete-css__title {
font-family: monospace;
font-size: 1.375rem;
color: #ffffff;
font-weight: bold;
margin-bottom: 16px;
}
.mission-complete-css__bar {
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
margin-bottom: 16px;
}
.mission-complete-css__bar-fill {
height: 100%;
background: linear-gradient(90deg, #00ffaa, #00ff88);
border-radius: 4px;
animation: mission-bar-fill 1s ease-out 0.4s both;
box-shadow: 0 0 15px #00ffaa;
}
.mission-complete-css__xp {
font-family: monospace;
font-size: 1.75rem;
color: #FFD700;
text-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
text-align: center;
animation: level-up-fade-in 0.5s ease-out 0.8s both;
}
@keyframes mission-bg {
0% { background: rgba(0, 10, 20, 0); }
10% { background: rgba(0, 10, 20, 0.75); }
85% { background: rgba(0, 10, 20, 0.75); }
100% { background: transparent; }
}
@keyframes mission-flip {
0% { transform: perspective(600px) rotateY(180deg); opacity: 0; }
60% { transform: perspective(600px) rotateY(-10deg); opacity: 1; }
100% { transform: perspective(600px) rotateY(0deg); opacity: 1; }
}
@keyframes mission-bar-fill {
0% { width: 0%; }
100% { width: 100%; }
}
CSS Fallback Vue Component
<!-- src/components/gamification/cyber/CssFallbackAnimation.vue -->
<template>
<Teleport to="body">
<!-- Level Up -->
<div v-if="type === 'level-up'" class="level-up-css" @animationend="onEnd">
<div class="level-up-css__text">LEVEL UP</div>
<div class="level-up-css__level">{{ props.newLevel }}</div>
<div class="level-up-css__xp">+{{ props.xpEarned }} XP</div>
</div>
<!-- Boss Defeat -->
<div v-if="type === 'boss-defeat'" class="boss-defeat-css shaking" @animationend="onEnd">
<div class="boss-defeat-css__name">{{ props.bossName }}</div>
<div class="boss-defeat-css__terminated">TERMINATED</div>
<div class="boss-defeat-css__rewards">+{{ props.rewards?.xp }} XP</div>
</div>
<!-- Mission Complete -->
<div v-if="type === 'mission-complete'" class="mission-complete-css" @animationend="onEnd">
<div class="mission-complete-css__card">
<div class="mission-complete-css__label">MISSION COMPLETE</div>
<div class="mission-complete-css__title">{{ props.missionTitle }}</div>
<div class="mission-complete-css__bar">
<div class="mission-complete-css__bar-fill" />
</div>
<div class="mission-complete-css__xp">+{{ props.xpReward }} XP</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
type: 'level-up' | 'boss-defeat' | 'mission-complete'
[key: string]: any
}>()
const emit = defineEmits<{ complete: [] }>()
function onEnd(e: AnimationEvent) {
// Only fire on the outermost container animation ending
if (e.target === e.currentTarget) {
emit('complete')
}
}
</script>
7. Asset Pipeline
Directory Structure
src/remotion/assets/
backgrounds/
cyber-grid.svg # Neon grid background
data-rain.svg # Matrix-style rain overlay
circuit-board.svg # Circuit pattern for cards
particles/
spark.svg # Single spark particle
hex-fragment.svg # Hexagonal fragment for explosions
glow-dot.svg # Radial glow dot
decorations/
frame-corners.svg # Cyberpunk frame corners
scanlines.svg # CRT scanline overlay
hp-bar-frame.svg # HP bar decorative frame
Asset Optimization
All SVGs must be SVGO-optimized before committing:
npm install -D svgo
npx svgo src/remotion/assets/**/*.svg --multipass --pretty
Color Tokens
Import from existing cyberflow design tokens where possible. In TSX files where CSS variables are unavailable, use hardcoded equivalents:
export const CYBER_COLORS = {
gold: '#FFD700',
neonCyan: '#00FFFF',
neonGreen: '#00FFAA',
neonPink: '#FF0040',
neonBlue: '#00CCFF',
darkBg: '#0a1628',
cardBg: '#1a2744',
hpRed: '#FF4444',
corruptionPurple: '#9B59B6',
platinum: '#E5E4E2',
silver: '#C0C0C0',
bronze: '#CD7F32',
} as const
Asset Generation
Nano Banana Pro (or similar AI image tools) can generate base concepts that are then refined as SVG. Workflow:
- Generate concept art at low resolution
- Trace to SVG in Inkscape or similar
- Simplify paths and optimize with SVGO
- Use CSS variables for colors where possible
8. Performance Optimization
Lazy-Loading Strategy
Remotion Player is ONLY imported when an animation is triggered. React, ReactDOM, and Remotion are separate chunks that never load unless needed.
const [React, ReactDOM, { Player }] = await Promise.all([
import('react'),
import('react-dom/client'),
import('@remotion/player'),
])
Intensity Gating
- minimal: No animations at all. Nothing loaded.
- moderate: CSS-only fallback animations + XpBurst micro-animation only.
- intense: Full Remotion animations for level-up, boss defeat, mission complete.
function shouldUseRemotion(): boolean {
const settings = useSettingsStore()
return settings.gamificationIntensity === 'intense'
}
Preload Hints
When the user enters the Cyberflow/Performance page at intense level, preload the Remotion chunk to warm the import cache:
import { watch } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import { useRoute } from 'vue-router'
export function useRemotionPreloader() {
const settings = useSettingsStore()
const route = useRoute()
watch(
[() => settings.gamificationIntensity, () => route.name],
([intensity, routeName]) => {
if (intensity === 'intense' && routeName === 'performance') {
import('@remotion/player')
import('react')
import('react-dom/client')
}
},
{ immediate: true }
)
}
Memory Management
Auto-cleanup after animation completes to prevent React tree from persisting in memory:
function unmountPlayer() {
if (reactRoot) {
reactRoot.unmount()
reactRoot = null
}
if (containerRef.value) {
const timeout = (containerRef.value as any).__completionTimeout
if (timeout) clearTimeout(timeout)
}
isPlaying.value = false
}
Bundle Size Expectations
| Package | Gzipped Size | Loaded When |
|---|
| react + react-dom | ~45KB | First Remotion animation trigger |
| @remotion/player | ~25KB | First Remotion animation trigger |
| Compositions (all) | ~5-10KB | First Remotion animation trigger |
| Total lazy chunk | ~75-80KB | Never at minimal/moderate |
9. Remotion CLI for Pre-rendering (Option C)
For static animations that don't need dynamic data, pre-render to video:
Render Commands
npx remotion render src/remotion/index.ts BossDefeatAnimation out/boss-defeat.webm --codec=vp9
npx remotion render src/remotion/index.ts LevelUpAnimation out/level-up.webm \
--props='{"oldLevel":5,"newLevel":6,"xpEarned":500,"tierColor":"#FFD700"}'
npx remotion render src/remotion/index.ts MissionCompleteAnimation out/mission-complete.mp4 \
--codec=h264
npx remotion still src/remotion/index.ts LevelUpAnimation out/frame-30.png --frame=30
Storage and Playback
Pre-rendered videos stored in public/animations/ and played via HTML5 video:
<video
v-if="showBossDefeat"
autoplay
muted
playsinline
class="fullscreen-animation"
@ended="onAnimationComplete"
>
<source src="/animations/boss-defeat.webm" type="video/webm" />
<source src="/animations/boss-defeat.mp4" type="video/mp4" />
</video>
.fullscreen-animation {
position: fixed;
inset: 0;
z-index: 99999;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
background: rgba(0, 0, 0, 0.8);
}
Pre-render Script
#!/bin/bash
set -e
OUT_DIR="public/animations"
mkdir -p "$OUT_DIR"
echo "Rendering Level Up animation..."
npx remotion render src/remotion/index.ts LevelUpAnimation "$OUT_DIR/level-up.webm" --codec=vp9
echo "Rendering Boss Defeat animation..."
npx remotion render src/remotion/index.ts BossDefeatAnimation "$OUT_DIR/boss-defeat.webm" --codec=vp9
echo "Rendering Mission Complete animation..."
npx remotion render src/remotion/index.ts MissionCompleteAnimation "$OUT_DIR/mission-complete.webm" --codec=vp9
echo "All animations rendered to $OUT_DIR"
10. Testing Remotion Animations
Remotion Studio (Visual Preview)
npx remotion studio src/remotion/index.ts
This opens a browser-based editor where you can:
- Scrub through frames
- Adjust input props live
- Preview at different resolutions
- Export individual frames
Still Frame Testing
npx remotion still src/remotion/index.ts LevelUpAnimation out/test/level-up-frame-0.png --frame=0
npx remotion still src/remotion/index.ts LevelUpAnimation out/test/level-up-frame-20.png --frame=20
npx remotion still src/remotion/index.ts LevelUpAnimation out/test/level-up-frame-45.png --frame=45
npx remotion still src/remotion/index.ts LevelUpAnimation out/test/level-up-frame-90.png --frame=90
Integration Test (Vue + Remotion Bridge)
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import RemotionBridge from '../RemotionBridge.vue'
vi.mock('react', () => ({
default: { createElement: vi.fn() },
}))
vi.mock('react-dom/client', () => ({
default: { createRoot: vi.fn(() => ({ render: vi.fn(), unmount: vi.fn() })) },
}))
vi.mock('@remotion/player', () => ({
Player: vi.fn(),
}))
describe('RemotionBridge', () => {
it('renders container element', () => {
const wrapper = mount(RemotionBridge, {
props: {
compositionId: 'LevelUpAnimation',
durationInFrames: 90,
},
})
expect(wrapper.find('.remotion-container').exists()).toBe(true)
})
it('emits complete after duration', async () => {
vi.useFakeTimers()
const wrapper = mount(RemotionBridge, {
props: {
compositionId: 'LevelUpAnimation',
durationInFrames: 90,
fps: 30,
},
})
await vi.advanceTimersByTimeAsync(3100)
expect(wrapper.emitted('complete')).toBeTruthy()
vi.useRealTimers()
})
})
11. Research Notes and Pre-Implementation Checklist
Before implementing Remotion in FlowState, the developer should verify:
-
Remotion version compatibility: Check latest Remotion docs for any Vue compatibility improvements. Remotion 5.x or later may have better non-React support or a headless player API.
-
Vite plugin coexistence: Verify that @vitejs/plugin-react (with include restriction to src/remotion/) co-exists with @vitejs/plugin-vue without conflicts. Test with npm run build before committing.
-
Bundle size audit: After installing React + Remotion, run npx vite-bundle-visualizer to verify the lazy-loaded chunks don't bloat the main bundle. All React/Remotion code should be in separate async chunks.
-
Lottie alternative: Consider if @lottiefiles/lottie-player or lottie-web might be lighter for simpler animations (XP burst, progress bars). Lottie has zero React dependency and ~25KB gzipped. Use Remotion only for complex multi-sequence animations.
-
Tauri compatibility: Test that Remotion Player renders correctly inside Tauri's WebView2/WebKit. Some canvas-based rendering may behave differently in embedded browsers.
-
Accessibility: All animations must respect prefers-reduced-motion. When the OS setting is enabled, skip all animations regardless of gamification intensity setting.
function shouldAnimate(): boolean {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return false
}
return useSettingsStore().gamificationIntensity !== 'minimal'
}
- SSR safety: Remotion uses browser APIs. Ensure all Remotion imports are behind dynamic
import() calls, never at top-level, to prevent SSR/build-time errors.