24 KiB
Particle Effects / Atmosphere Panel Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the single fog toggle in the campaign header with an atmosphere panel supporting fog (CSS, unchanged), fire, rain, and embers (tsParticles), each with an on/off toggle and intensity slider synced via socket.
Architecture: AtmospherePanel is a self-contained popover component that receives atmosphere state and an onAtmosphereChange callback from CampaignView. CampaignView owns socket emit and state. ParticleOverlay renders a tsParticles instance per active particle effect (fire/rain/embers). FogOverlay is unchanged — fog stays as CSS.
Tech Stack: React 18, TypeScript, @tsparticles/react, @tsparticles/slim, Socket.IO client, CSS Modules
File Map
| File | Action |
|---|---|
client/src/lib/atmosphereTypes.ts |
Create — shared types and default state |
client/src/lib/particleConfigs.ts |
Create — pure config functions per effect |
client/src/components/ParticleOverlay.tsx |
Create — tsParticles renderer |
client/src/components/AtmospherePanel.tsx |
Create — button + popover |
client/src/components/AtmospherePanel.module.css |
Create — panel styles |
client/src/pages/CampaignView.tsx |
Modify — state, socket, JSX |
server/src/socket.ts |
Modify — update TypeScript type |
client/package.json |
Modify — add tsParticles packages |
Task 1: Install tsParticles and define shared types
Files:
-
Modify:
client/package.json -
Create:
client/src/lib/atmosphereTypes.ts -
Step 1: Install packages
cd /path/to/shadowdark/client
npm install @tsparticles/react @tsparticles/slim
Expected output: packages added to node_modules, package.json updated with both deps.
- Step 2: Verify installation
ls node_modules/@tsparticles/
Expected: engine react slim directories visible.
- Step 3: Create shared types file
Create client/src/lib/atmosphereTypes.ts:
export interface EffectState {
active: boolean;
intensity: number; // 0–100
}
export interface AtmosphereState {
fog: EffectState;
fire: EffectState;
rain: EffectState;
embers: EffectState;
}
export const defaultAtmosphere: AtmosphereState = {
fog: { active: false, intensity: 50 },
fire: { active: false, intensity: 50 },
rain: { active: false, intensity: 50 },
embers: { active: false, intensity: 50 },
};
- Step 4: Verify TypeScript compiles
cd client
npx tsc --noEmit
Expected: no errors.
- Step 5: Commit
git add client/package.json client/package-lock.json client/src/lib/atmosphereTypes.ts
git commit -m "feat: install tsParticles and add atmosphere types"
Task 2: Implement particle configs
Files:
-
Create:
client/src/lib/particleConfigs.ts -
Step 1: Create particle configs file
Create client/src/lib/particleConfigs.ts:
import type { ISourceOptions } from "@tsparticles/engine";
/** Intensity 0–100 → fire particle count 20–200, speed 2–8 */
export function getFireConfig(intensity: number): ISourceOptions {
const count = Math.round(20 + (intensity / 100) * 180);
const speed = 2 + (intensity / 100) * 6;
return {
fullScreen: { enable: false },
background: { opacity: 0 },
particles: {
number: { value: count, density: { enable: false } },
color: { value: ["#ff4400", "#ff8800", "#ffcc00", "#ff2200"] },
shape: { type: "circle" },
opacity: {
value: { min: 0.1, max: 0.8 },
animation: { enable: true, speed: 2, startValue: "max", destroy: "min" },
},
size: { value: { min: 1, max: 4 } },
move: {
enable: true,
speed: { min: speed * 0.5, max: speed },
direction: "top",
random: true,
straight: false,
outModes: { default: "destroy", top: "destroy" },
},
life: { duration: { sync: false, value: 3 }, count: 1 },
},
emitters: {
direction: "top",
life: { count: 0, duration: 0.1, delay: 0.1 },
rate: { delay: 0.05, quantity: Math.max(1, Math.round(count / 20)) },
size: { width: 100, height: 0 },
position: { x: 50, y: 100 },
},
};
}
/** Intensity 0–100 → rain particle count 50–500, speed 5–20 */
export function getRainConfig(intensity: number): ISourceOptions {
const count = Math.round(50 + (intensity / 100) * 450);
const speed = 5 + (intensity / 100) * 15;
return {
fullScreen: { enable: false },
background: { opacity: 0 },
particles: {
number: { value: count, density: { enable: false } },
color: { value: "#7aaad8" },
shape: { type: "circle" },
opacity: { value: { min: 0.2, max: 0.45 } },
size: { value: { min: 0.5, max: 1.5 } },
move: {
enable: true,
speed: speed,
direction: "bottom",
straight: true,
angle: { value: 15, offset: 0 },
outModes: { default: "out" },
},
},
};
}
/** Intensity 0–100 → embers particle count 5–80, speed 0.2–1.5 */
export function getEmbersConfig(intensity: number): ISourceOptions {
const count = Math.round(5 + (intensity / 100) * 75);
const speed = 0.2 + (intensity / 100) * 1.3;
return {
fullScreen: { enable: false },
background: { opacity: 0 },
particles: {
number: { value: count, density: { enable: false } },
color: { value: ["#ff8800", "#ffaa00", "#ffcc44", "#ff6600"] },
shape: { type: "circle" },
opacity: {
value: { min: 0.3, max: 0.9 },
animation: { enable: true, speed: 0.5, sync: false },
},
size: { value: { min: 1, max: 3 } },
move: {
enable: true,
speed: speed,
direction: "top",
random: true,
straight: false,
outModes: { default: "out" },
},
},
};
}
- Step 2: Verify TypeScript compiles
cd client && npx tsc --noEmit
Expected: no errors. If ISourceOptions import fails, check @tsparticles/engine is a transitive dep of @tsparticles/slim — it should be. If not, run npm install @tsparticles/engine.
- Step 3: Commit
git add client/src/lib/particleConfigs.ts
git commit -m "feat: add tsParticles configs for fire, rain, embers"
Task 3: Implement ParticleOverlay
Files:
-
Create:
client/src/components/ParticleOverlay.tsx -
Step 1: Create ParticleOverlay component
Create client/src/components/ParticleOverlay.tsx:
import { useEffect, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import { getFireConfig, getRainConfig, getEmbersConfig } from "../lib/particleConfigs";
// Module-level singleton so the engine is only initialised once
let enginePromise: Promise<void> | null = null;
function ensureEngine(): Promise<void> {
if (!enginePromise) {
enginePromise = initParticlesEngine(async (engine) => {
await loadSlim(engine);
});
}
return enginePromise;
}
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
zIndex: 9997,
pointerEvents: "none",
};
const instanceStyle: React.CSSProperties = {
position: "absolute",
inset: 0,
};
interface ParticleOverlayProps {
atmosphere: AtmosphereState;
}
export default function ParticleOverlay({ atmosphere }: ParticleOverlayProps) {
const [ready, setReady] = useState(false);
useEffect(() => {
ensureEngine().then(() => setReady(true));
}, []);
if (!ready) return null;
const { fire, rain, embers } = atmosphere;
const anyActive = fire.active || rain.active || embers.active;
if (!anyActive) return null;
return (
<div style={overlayStyle}>
{fire.active && (
<Particles
key={`fire-${fire.intensity}`}
id="particles-fire"
options={getFireConfig(fire.intensity)}
style={instanceStyle}
/>
)}
{rain.active && (
<Particles
key={`rain-${rain.intensity}`}
id="particles-rain"
options={getRainConfig(rain.intensity)}
style={instanceStyle}
/>
)}
{embers.active && (
<Particles
key={`embers-${embers.intensity}`}
id="particles-embers"
options={getEmbersConfig(embers.intensity)}
style={instanceStyle}
/>
)}
</div>
);
}
Note: key={effect-${intensity}} causes the Particles component to remount when intensity changes (committed via slider release), giving clean re-initialization. This avoids stale container state.
- Step 2: Verify TypeScript compiles
cd client && npx tsc --noEmit
Expected: no errors.
- Step 3: Commit
git add client/src/components/ParticleOverlay.tsx
git commit -m "feat: add ParticleOverlay component for tsParticles effects"
Task 4: Style AtmospherePanel
Files:
-
Create:
client/src/components/AtmospherePanel.module.css -
Step 1: Create CSS module
Create client/src/components/AtmospherePanel.module.css:
/* Container holds trigger + popover — position relative so popover anchors here */
.container {
position: relative;
}
/* Trigger button — matches fogBtn style from CampaignView.module.css */
.trigger {
padding: 0.4rem 0.5rem;
background: none;
border: 1px solid rgba(var(--gold-rgb), 0.2);
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: var(--text-primary);
opacity: 0.5;
transition:
opacity 0.15s,
border-color 0.15s;
white-space: nowrap;
}
.trigger:hover {
opacity: 0.8;
border-color: rgba(var(--gold-rgb), 0.4);
}
/* When any effect is active */
.triggerActive {
opacity: 1;
border-color: rgba(var(--gold-rgb), 0.5);
background: rgba(var(--gold-rgb), 0.1);
}
/* Popover panel */
.panel {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
background-color: var(--bg-modal);
background-image: var(--texture-surface);
background-size: 256px 256px;
background-repeat: repeat;
border: 1px solid rgba(var(--gold-rgb), 0.3);
border-radius: 4px;
padding: 0.5rem 0.6rem;
min-width: 180px;
z-index: 9999;
box-shadow:
0 4px 16px rgba(var(--shadow-rgb), 0.5),
inset 0 1px 0 rgba(var(--gold-rgb), 0.06);
}
.panelTitle {
font-family: "Cinzel", Georgia, serif;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(var(--gold-rgb), 0.6);
border-bottom: 1px solid rgba(var(--gold-rgb), 0.15);
padding-bottom: 0.35rem;
margin-bottom: 0.4rem;
}
/* One row per effect */
.effectRow {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.3rem 0;
border-bottom: 1px solid rgba(var(--gold-rgb), 0.08);
}
.effectRow:last-child {
border-bottom: none;
}
.effectTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.effectLabel {
font-family: "Alegreya", Georgia, serif;
font-size: 0.85rem;
color: var(--text-primary);
}
/* Toggle switch */
.toggle {
position: relative;
width: 32px;
height: 17px;
flex-shrink: 0;
border-radius: 9px;
background: rgba(var(--gold-rgb), 0.1);
border: 1px solid rgba(var(--gold-rgb), 0.2);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(var(--gold-rgb), 0.35);
transition: transform 0.2s, background 0.2s;
}
.toggleOn {
background: rgba(var(--gold-rgb), 0.25);
border-color: rgba(var(--gold-rgb), 0.5);
}
.toggleOn::after {
transform: translateX(15px);
background: var(--gold);
}
/* Intensity row — dimmed when effect is off */
.intensityRow {
display: flex;
align-items: center;
gap: 0.4rem;
opacity: 0.3;
transition: opacity 0.15s;
}
.intensityActive {
opacity: 1;
}
.intensityLabel {
font-family: "Alegreya", Georgia, serif;
font-size: 0.75rem;
color: var(--text-secondary);
width: 2.2rem;
text-align: right;
flex-shrink: 0;
}
.slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
border-radius: 2px;
background: rgba(var(--gold-rgb), 0.15);
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(var(--gold-rgb), 0.4);
cursor: pointer;
transition: background 0.15s;
}
.slider:not(:disabled)::-webkit-slider-thumb {
background: var(--gold);
}
.slider::-moz-range-thumb {
width: 11px;
height: 11px;
border-radius: 50%;
border: none;
background: var(--gold);
cursor: pointer;
}
- Step 2: Commit
git add client/src/components/AtmospherePanel.module.css
git commit -m "feat: add AtmospherePanel CSS module"
Task 5: Implement AtmospherePanel component
Files:
-
Create:
client/src/components/AtmospherePanel.tsx -
Step 1: Create AtmospherePanel component
Create client/src/components/AtmospherePanel.tsx:
import { useEffect, useRef, useState } from "react";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import styles from "./AtmospherePanel.module.css";
type EffectKey = keyof AtmosphereState;
const EFFECTS: { key: EffectKey; emoji: string; label: string }[] = [
{ key: "fog", emoji: "🌫", label: "Fog" },
{ key: "fire", emoji: "🔥", label: "Fire" },
{ key: "rain", emoji: "🌧", label: "Rain" },
{ key: "embers", emoji: "✨", label: "Embers" },
];
interface AtmospherePanelProps {
atmosphere: AtmosphereState;
onAtmosphereChange: (next: AtmosphereState) => void;
}
export default function AtmospherePanel({
atmosphere,
onAtmosphereChange,
}: AtmospherePanelProps) {
const [open, setOpen] = useState(false);
// Local slider values so the label updates while dragging without triggering remounts
const [localIntensity, setLocalIntensity] = useState({
fog: atmosphere.fog.intensity,
fire: atmosphere.fire.intensity,
rain: atmosphere.rain.intensity,
embers: atmosphere.embers.intensity,
});
const containerRef = useRef<HTMLDivElement>(null);
// Close panel on outside click
useEffect(() => {
if (!open) return;
function onMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open]);
// Sync local intensity when atmosphere changes from socket
useEffect(() => {
setLocalIntensity({
fog: atmosphere.fog.intensity,
fire: atmosphere.fire.intensity,
rain: atmosphere.rain.intensity,
embers: atmosphere.embers.intensity,
});
}, [atmosphere]);
const activeEmojis = EFFECTS
.filter(({ key }) => atmosphere[key].active)
.map(({ emoji }) => emoji)
.join("");
function toggleEffect(key: EffectKey) {
onAtmosphereChange({
...atmosphere,
[key]: { ...atmosphere[key], active: !atmosphere[key].active },
});
}
function handleSliderChange(key: EffectKey, value: number) {
// Update label only — no socket emit yet
setLocalIntensity((prev) => ({ ...prev, [key]: value }));
}
function handleSliderCommit(key: EffectKey, value: number) {
// Emit on mouse/touch release
onAtmosphereChange({
...atmosphere,
[key]: { ...atmosphere[key], intensity: value },
});
}
return (
<div className={styles.container} ref={containerRef}>
<button
className={`${styles.trigger} ${activeEmojis ? styles.triggerActive : ""}`}
onClick={() => setOpen((o) => !o)}
title="Atmosphere effects"
>
{activeEmojis || "🌫"} ▾
</button>
{open && (
<div className={styles.panel}>
<div className={styles.panelTitle}>Atmosphere</div>
{EFFECTS.map(({ key, emoji, label }) => (
<div key={key} className={styles.effectRow}>
<div className={styles.effectTop}>
<span className={styles.effectLabel}>{emoji} {label}</span>
<button
className={`${styles.toggle} ${atmosphere[key].active ? styles.toggleOn : ""}`}
onClick={() => toggleEffect(key)}
aria-label={`Toggle ${label}`}
/>
</div>
<div
className={`${styles.intensityRow} ${
atmosphere[key].active ? styles.intensityActive : ""
}`}
>
<span className={styles.intensityLabel}>
{localIntensity[key]}%
</span>
<input
type="range"
min={0}
max={100}
value={localIntensity[key]}
disabled={!atmosphere[key].active}
className={styles.slider}
onChange={(e) =>
handleSliderChange(key, Number(e.target.value))
}
onMouseUp={(e) =>
handleSliderCommit(
key,
Number((e.target as HTMLInputElement).value),
)
}
onTouchEnd={(e) =>
handleSliderCommit(
key,
Number((e.currentTarget as HTMLInputElement).value),
)
}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}
- Step 2: Verify TypeScript compiles
cd client && npx tsc --noEmit
Expected: no errors.
- Step 3: Commit
git add client/src/components/AtmospherePanel.tsx
git commit -m "feat: add AtmospherePanel component with toggles and intensity sliders"
Task 6: Update CampaignView
Files:
-
Modify:
client/src/pages/CampaignView.tsx -
Step 1: Update imports
In CampaignView.tsx, replace the existing FogOverlay import block with:
import FogOverlay from "../components/FogOverlay";
import AtmospherePanel from "../components/AtmospherePanel";
import ParticleOverlay from "../components/ParticleOverlay";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import { defaultAtmosphere } from "../lib/atmosphereTypes";
- Step 2: Replace fogActive state
Find and remove:
const [fogActive, setFogActive] = useState(false);
Add in its place:
const [atmosphere, setAtmosphere] = useState<AtmosphereState>(defaultAtmosphere);
- Step 3: Add atmosphere change handler
After the state declarations, add:
function handleAtmosphereChange(next: AtmosphereState) {
setAtmosphere(next);
socket.emit("atmosphere:update", { campaignId, ...next });
}
- Step 4: Update socket listener
Find the existing atmosphere:update listener:
socket.on("atmosphere:update", (data: { fog: boolean }) => {
setFogActive(data.fog);
});
Replace with:
socket.on("atmosphere:update", (data: AtmosphereState) => {
setAtmosphere(data);
});
Also update the socket.off call:
socket.off("atmosphere:update");
(No change needed — it's already correct.)
- Step 5: Replace fog button in JSX with AtmospherePanel
Find in JSX:
<button
className={`${styles.fogBtn} ${fogActive ? styles.fogBtnActive : ""}`}
onClick={() => {
const next = !fogActive;
setFogActive(next);
socket.emit("atmosphere:update", {
campaignId,
fog: next,
});
}}
title={fogActive ? "Clear fog" : "Summon fog"}
>
🌫
</button>
Replace with:
<AtmospherePanel
atmosphere={atmosphere}
onAtmosphereChange={handleAtmosphereChange}
/>
- Step 6: Update FogOverlay and add ParticleOverlay
Find near the bottom of the JSX:
<FogOverlay active={fogActive} />
Replace with:
<FogOverlay active={atmosphere.fog.active} />
<ParticleOverlay atmosphere={atmosphere} />
- Step 7: Verify TypeScript compiles
cd client && npx tsc --noEmit
Expected: no errors. Common issue: if fogBtn/fogBtnActive CSS classes are now unused in the CSS module but referenced nowhere, TypeScript won't complain — leave them in the CSS for now or remove if desired.
- Step 8: Commit
git add client/src/pages/CampaignView.tsx
git commit -m "feat: wire AtmospherePanel and ParticleOverlay into CampaignView"
Task 7: Update server socket type
Files:
-
Modify:
server/src/socket.ts -
Step 1: Update the atmosphere:update handler type
In server/src/socket.ts, find:
socket.on(
"atmosphere:update",
(data: { campaignId: number; fog: boolean }) => {
io.to(`campaign:${data.campaignId}`).emit("atmosphere:update", {
fog: data.fog,
});
},
);
Replace with:
interface EffectState {
active: boolean;
intensity: number;
}
interface AtmosphereUpdateData {
campaignId: number;
fog: EffectState;
fire: EffectState;
rain: EffectState;
embers: EffectState;
}
socket.on("atmosphere:update", (data: AtmosphereUpdateData) => {
const { campaignId, ...atmosphere } = data;
io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere);
});
Place the EffectState and AtmosphereUpdateData interfaces at the top of the socket.ts file (or just above the handler — either works since this file has no other shared types).
- Step 2: Verify server TypeScript compiles
cd server && npx tsc --noEmit
Expected: no errors.
- Step 3: Commit
git add server/src/socket.ts
git commit -m "feat: update atmosphere:update socket type for full effect state"
Task 8: Visual verification
Files: none
- Step 1: Start the server and client
In two terminals:
# Terminal 1
cd server && npm run dev
# Terminal 2
cd client && npm run dev
- Step 2: Open the app and navigate to a campaign
Open http://localhost:5173 (or whatever Vite port). Open or create a campaign.
- Step 3: Verify the fog button is replaced
The header should show a single 🌫 ▾ button where the fog button was. Clicking it should open a popover with 4 effect rows (Fog, Fire, Rain, Embers), each with a toggle and a dimmed slider.
- Step 4: Test fog (CSS)
Toggle Fog on. The CSS fog overlay should drift across the screen (unchanged from before). Toggle it off — fog clears.
- Step 5: Test fire
Toggle Fire on. Particles should rise from the bottom — orange/red/yellow sparks. Adjust the slider and release — particle density should visibly change. Toggle off — particles stop.
- Step 6: Test rain
Toggle Rain on. Blue-ish particles should fall from the top. High intensity = heavy rain. Toggle off.
- Step 7: Test embers
Toggle Embers on. Slow-drifting amber dots should float upward. Toggle off.
- Step 8: Test stacking
Enable Fog + Embers simultaneously. Both should render correctly — fog CSS overlay above the ember particles (z-index 9998 vs 9997).
- Step 9: Test socket sync
Open a second browser tab on the same campaign. Toggle fire on in one tab — verify it appears in the other tab. Adjust intensity and release slider — verify it syncs to the other tab.
- Step 10: Commit if any tweaks were made
git add -p # stage only intentional changes
git commit -m "fix: tweak particle configs after visual verification"
If particle effects look poor out of the box, adjust the count/speed/color values in particleConfigs.ts. The tsParticles config reference is at https://particles.js.org/docs/