darkwatch/docs/superpowers/plans/2026-04-10-particle-effects.md
Aaron Wood 7ffb16d08b Add particle effects implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:59:46 -04:00

24 KiB
Raw Blame History

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; // 0100
}

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 0100 → fire particle count 20200, speed 28 */
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 0100 → rain particle count 50500, speed 520 */
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 0100 → embers particle count 580, speed 0.21.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/