diff --git a/public/js/ghost.js b/public/js/ghost.js
new file mode 100644
index 0000000..bdf6e00
--- /dev/null
+++ b/public/js/ghost.js
@@ -0,0 +1,174 @@
+import * as THREE from "three";
+
+// Procedural fallback tints (used when a ghost has no GIF asset)
+const GHOST_TINTS = [
+ { core: 0x9fe6ff, glow: 0x2ffbe6, name: "WISP" },
+ { core: 0xff9fd0, glow: 0xff2d8f, name: "POLTERGEIST" },
+ { core: 0xd4ff9f, glow: 0xb6ff3c, name: "PHANTOM" },
+ { core: 0xc9b3ff, glow: 0x9b6dff, name: "SHADE" },
+];
+
+/**
+ * Ghost.
+ * @param scene THREE.Scene
+ * @param position THREE.Vector3
+ * @param spec optional { name, gif_url, scale, rarity } from the API.
+ * When gif_url is present the ghost renders as an animated
+ * billboard; otherwise it falls back to the procedural wisp.
+ */
+export class Ghost {
+ constructor(scene, position, spec = null) {
+ this.scene = scene;
+ this.spec = spec;
+ this.tint = GHOST_TINTS[Math.floor(Math.random() * GHOST_TINTS.length)];
+ this.displayName = spec?.name?.toUpperCase() || this.tint.name;
+ this.userScale = spec?.scale ? Number(spec.scale) : 1.0;
+
+ this.group = new THREE.Group();
+ this.group.position.copy(position);
+ this.basePos = position.clone();
+ this.captured = false;
+ this.escaped = false;
+ this.captureProgress = 0;
+ this.spawnTime = performance.now();
+ this.lifespan = 14000 + Math.random() * 8000; // 14-22s before it flees
+ this.phase = Math.random() * Math.PI * 2;
+ this.bobSpeed = 0.6 + Math.random() * 0.5;
+ this.drift = new THREE.Vector3(
+ (Math.random() - 0.5) * 0.0006,
+ (Math.random() - 0.5) * 0.0003,
+ (Math.random() - 0.5) * 0.0006
+ );
+
+ if (spec?.gif_url) this._buildBillboard(spec.gif_url);
+ else this._buildProcedural();
+
+ scene.add(this.group);
+ }
+
+ /* ---------- GIF / image billboard ---------- */
+ _buildBillboard(url) {
+ // An
drives the texture; animated GIFs keep animating in the
,
+ // and we re-upload the frame to the GPU each render via texture.needsUpdate.
+ this.img = new Image();
+ this.img.crossOrigin = "anonymous";
+ this.img.src = url;
+
+ const tex = new THREE.Texture(this.img);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ tex.minFilter = THREE.LinearFilter;
+ this.texture = tex;
+ this._imgReady = false;
+ this.img.onload = () => { tex.needsUpdate = true; this._imgReady = true; this._fitAspect(); };
+
+ const mat = new THREE.MeshBasicMaterial({
+ map: tex, transparent: true, side: THREE.DoubleSide, depthWrite: false,
+ });
+ const geo = new THREE.PlaneGeometry(0.5, 0.5);
+ this.sprite = new THREE.Mesh(geo, mat);
+ this.group.add(this.sprite);
+ this.group.scale.setScalar(this.userScale);
+
+ // Soft glow halo behind the sprite for a supernatural feel
+ const haloMat = new THREE.MeshBasicMaterial({
+ color: this.tint.glow, transparent: true, opacity: 0.12,
+ blending: THREE.AdditiveBlending, depthWrite: false,
+ });
+ this.halo = new THREE.Mesh(new THREE.CircleGeometry(0.35, 24), haloMat);
+ this.halo.position.z = -0.02;
+ this.group.add(this.halo);
+
+ this.mode = "billboard";
+ }
+
+ _fitAspect() {
+ if (!this.img?.naturalWidth) return;
+ const aspect = this.img.naturalWidth / this.img.naturalHeight;
+ const h = 0.5, w = h * aspect;
+ this.sprite.geometry.dispose();
+ this.sprite.geometry = new THREE.PlaneGeometry(w, h);
+ }
+
+ /* ---------- Procedural wisp fallback ---------- */
+ _buildProcedural() {
+ const t = this.tint;
+ const bodyGeo = new THREE.SphereGeometry(0.16, 24, 24);
+ bodyGeo.scale(1, 1.15, 1);
+ this.body = new THREE.Mesh(bodyGeo, new THREE.MeshBasicMaterial({ color: t.core, transparent: true, opacity: 0.55 }));
+ this.group.add(this.body);
+
+ const core = new THREE.Mesh(new THREE.SphereGeometry(0.1, 16, 16),
+ new THREE.MeshBasicMaterial({ color: t.glow, transparent: true, opacity: 0.35 }));
+ core.position.y = 0.02; this.group.add(core);
+
+ this.halo = new THREE.Mesh(new THREE.SphereGeometry(0.26, 20, 20),
+ new THREE.MeshBasicMaterial({ color: t.glow, transparent: true, opacity: 0.12, blending: THREE.AdditiveBlending, side: THREE.BackSide }));
+ this.group.add(this.halo);
+
+ this.tendrils = [];
+ for (let i = 0; i < 3; i++) {
+ const td = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.22, 8),
+ new THREE.MeshBasicMaterial({ color: t.core, transparent: true, opacity: 0.4 }));
+ td.position.set((i - 1) * 0.06, -0.18, 0); td.rotation.x = Math.PI;
+ this.group.add(td); this.tendrils.push(td);
+ }
+
+ const eyeMat = new THREE.MeshBasicMaterial({ color: 0x05070f });
+ const eyeGeo = new THREE.SphereGeometry(0.022, 10, 10);
+ const eyeL = new THREE.Mesh(eyeGeo, eyeMat); const eyeR = new THREE.Mesh(eyeGeo, eyeMat);
+ eyeL.position.set(-0.05, 0.03, 0.14); eyeR.position.set(0.05, 0.03, 0.14);
+ this.group.add(eyeL, eyeR);
+
+ this.mode = "procedural";
+ }
+
+ update(dt, camera) {
+ if (this.captured || this.escaped) return;
+ const now = performance.now();
+ const age = now - this.spawnTime;
+ const tt = now * 0.001;
+
+ // Bob + sway
+ this.group.position.x = this.basePos.x + Math.sin(tt * this.bobSpeed + this.phase) * 0.05;
+ this.group.position.y = this.basePos.y + Math.sin(tt * this.bobSpeed * 1.3 + this.phase) * 0.06;
+ this.group.position.z = this.basePos.z + Math.cos(tt * this.bobSpeed + this.phase) * 0.05;
+ this.basePos.add(this.drift);
+
+ if (this.mode === "billboard") {
+ // Keep pumping animated-GIF frames to the GPU
+ if (this._imgReady) this.texture.needsUpdate = true;
+ // Face the camera fully (billboard)
+ if (camera) this.group.quaternion.copy(camera.quaternion);
+ if (this.halo) this.halo.scale.setScalar(1 + Math.sin(tt * 2) * 0.08);
+ } else {
+ this.tendrils?.forEach((td, i) => { td.rotation.z = Math.sin(tt * 3 + i) * 0.3; });
+ if (this.halo) this.halo.scale.setScalar(1 + Math.sin(tt * 2) * 0.08);
+ if (camera) this.group.lookAt(camera.position.x, this.group.position.y, camera.position.z);
+ }
+
+ // Capture shrink/spin
+ if (this.captureProgress > 0) {
+ const s = (this.mode === "billboard" ? this.userScale : 1) * (1 - this.captureProgress * 0.5);
+ this.group.scale.setScalar(s);
+ if (this.mode === "procedural") this.group.rotation.y += dt * 6;
+ }
+
+ if (age > this.lifespan && this.captureProgress < 0.05) this.escaped = true;
+ }
+
+ getWorldPosition(target) { return this.group.getWorldPosition(target); }
+ setCaptureProgress(p) { this.captureProgress = Math.min(1, Math.max(0, p)); }
+ capture() { this.captured = true; return this.displayName; }
+
+ dispose() {
+ this.scene.remove(this.group);
+ this.group.traverse((o) => {
+ if (o.geometry) o.geometry.dispose();
+ if (o.material) o.material.dispose();
+ });
+ if (this.texture) this.texture.dispose();
+ if (this.img) { this.img.onload = null; this.img.src = ""; }
+ }
+}
+
+export { GHOST_TINTS };