diff --git a/src/main/java/com/mythlane/chainlightning/chain/VolumeCurve.java b/src/main/java/com/mythlane/chainlightning/chain/VolumeCurve.java
new file mode 100644
index 0000000..016eb56
--- /dev/null
+++ b/src/main/java/com/mythlane/chainlightning/chain/VolumeCurve.java
@@ -0,0 +1,40 @@
+package com.mythlane.chainlightning.chain;
+
+/**
+ * Pure-Java volume curve for the chain-lightning sound emission.
+ *
+ * Each successive hop in the chain emits its sound at a progressively lower
+ * volume, per VFX-02. The curve is hard-coded to the spec values
+ * {@code [1.0, 0.8, 0.6, 0.5, 0.4]} indexed by hop number.
+ *
+ * This class has ZERO Hytale imports — Phase 2 sealed-frontier rule preserved.
+ */
+public final class VolumeCurve {
+
+ /** Spec curve from VFX-02. Order and values are exact. */
+ private static final float[] CURVE = {1.0f, 0.8f, 0.6f, 0.5f, 0.4f};
+
+ private VolumeCurve() {
+ // utility class — no instances
+ }
+
+ /**
+ * Volume for a given hop index in the chain.
+ *
+ * - Indices 0..4 return the spec values {@code 1.0, 0.8, 0.6, 0.5, 0.4}.
+ * - Indices ≥ 5 clamp to the last value {@code 0.4f}.
+ * - Negative indices throw {@link IllegalArgumentException}.
+ *
+ *
+ * @param hopIndex zero-based hop index
+ * @return volume scalar in the range (0, 1]
+ * @throws IllegalArgumentException if {@code hopIndex < 0}
+ */
+ public static float volumeFor(int hopIndex) {
+ if (hopIndex < 0) {
+ throw new IllegalArgumentException("hopIndex must be >= 0, got " + hopIndex);
+ }
+ int clamped = Math.min(hopIndex, CURVE.length - 1);
+ return CURVE[clamped];
+ }
+}
diff --git a/src/test/java/com/mythlane/chainlightning/chain/VolumeCurveTest.java b/src/test/java/com/mythlane/chainlightning/chain/VolumeCurveTest.java
new file mode 100644
index 0000000..81fc126
--- /dev/null
+++ b/src/test/java/com/mythlane/chainlightning/chain/VolumeCurveTest.java
@@ -0,0 +1,31 @@
+package com.mythlane.chainlightning.chain;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+final class VolumeCurveTest {
+
+ @Test
+ void specValues() {
+ assertEquals(1.0f, VolumeCurve.volumeFor(0), 1e-6);
+ assertEquals(0.8f, VolumeCurve.volumeFor(1), 1e-6);
+ assertEquals(0.6f, VolumeCurve.volumeFor(2), 1e-6);
+ assertEquals(0.5f, VolumeCurve.volumeFor(3), 1e-6);
+ assertEquals(0.4f, VolumeCurve.volumeFor(4), 1e-6);
+ }
+
+ @Test
+ void indexClamp() {
+ assertEquals(0.4f, VolumeCurve.volumeFor(5), 1e-6);
+ assertEquals(0.4f, VolumeCurve.volumeFor(99), 1e-6);
+ assertEquals(0.4f, VolumeCurve.volumeFor(Integer.MAX_VALUE), 1e-6);
+ }
+
+ @Test
+ void negativeIndexThrows() {
+ assertThrows(IllegalArgumentException.class, () -> VolumeCurve.volumeFor(-1));
+ assertThrows(IllegalArgumentException.class, () -> VolumeCurve.volumeFor(Integer.MIN_VALUE));
+ }
+}