Skip to content

Tier A: Decorative Mod CLIENT-ONLY ​

Build night-time fireflies from scratch - deterministic, weather-aware, quality-scaled. This is the real architecture of the first-party glow-fireflies mod.

Client code guide

This walkthrough uses the same React/R3F client-surface model available to reviewed public marketplace mods through entries.client. Workspace mods can still use "runtime": "internal" for local first-party development.

What tier A means ​

No server entry, no replication, no permissions for the core loop. Every client computes the same visuals from the world seed. Costs the network nothing.

1. Manifest ​

json
{
  "id": "my-fireflies",
  "name": "My Fireflies",
  "version": "0.1.0",
  "apiVersion": 2,
  "runtime": "public",
  "tier": "A",
  "entries": { "client": "client/index.jsx" },
  "permissions": [],
  "configSchema": {
    "type": "object",
    "properties": {
      "density": { "type": "number", "default": 1, "minimum": 0, "maximum": 3 }
    }
  }
}

configSchema gives server owners a validated per-world knob - you read it with useModConfig().

2. Deterministic placement on real terrain ​

jsx
import { useMemo } from 'react';
import {
  useDeterministicRng,
  useModConfig,
  useTerrainSampler,
  useWorldInfo,
} from '@vibelands/mod-sdk/client';

const BASE_COUNT = 240;

export function useFireflySpots() {
  const rng = useDeterministicRng('spots');        // same on every client
  const getGroundHeight = useTerrainSampler();
  const { terrain } = useWorldInfo();
  const config = useModConfig();

  return useMemo(() => {
    const count = Math.round(BASE_COUNT * (config.density ?? 1));
    const radius = terrain.size * 0.4;
    const spots = [];
    if (!getGroundHeight) return spots;
    // Bound attempts: some worlds have no suitable dry land.
    for (let attempt = 0; spots.length < count && attempt < count * 20; attempt += 1) {
      const x = (rng() * 2 - 1) * radius;
      const z = (rng() * 2 - 1) * radius;
      const ground = getGroundHeight(x, z);
      if (ground < terrain.seaLevel + 0.5) continue;   // dry land only
      spots.push({ x, y: ground + 0.6 + rng() * 1.4, z, phase: rng() * Math.PI * 2 });
    }
    return spots;
  }, [rng, getGroundHeight, terrain, config.density]);
}

The determinism rule

Anything shared-looking (positions, counts, colors) must come from useDeterministicRng. If you use Math.random(), every player sees fireflies in different places - shared placement becomes inconsistent.

3. Animate cheap: one geometry, mutate attributes ​

React state per firefly would re-render hundreds of components per frame. Instead: one <points> cloud, animated by mutating the buffer attribute inside useModFrame.

jsx
import { useMemo, useRef } from 'react';
import {
  useAtmosphereFrame,
  useGraphicsQuality,
  useModFrame,
} from '@vibelands/mod-sdk/client';
import { useFireflySpots } from './useFireflySpots';

export function FirefliesLayer() {
  const spots = useFireflySpots();
  const quality = useGraphicsQuality();
  const atmosphereRef = useAtmosphereFrame();
  const materialRef = useRef();
  const geometryRef = useRef();

  // Scale content like the core systems do.
  const visible = quality === 'low' ? Math.floor(spots.length * 0.4) : spots.length;

  const positions = useMemo(() => {
    const array = new Float32Array(visible * 3);
    spots.slice(0, visible).forEach((spot, i) => {
      array[i * 3] = spot.x; array[i * 3 + 1] = spot.y; array[i * 3 + 2] = spot.z;
    });
    return array;
  }, [spots, visible]);

  useModFrame((state) => {
    const atmosphere = atmosphereRef.current;
    // Night-only: fade with daylight, hide in storms. ~0 activity → skip work.
    const activity = (1 - atmosphere.daylight) * (1 - 0.8 * atmosphere.weatherIntensity);
    if (materialRef.current) materialRef.current.opacity = activity * 0.9;
    if (activity < 0.05) return;

    const t = state.clock.elapsedTime;
    const array = geometryRef.current?.attributes.position.array;
    if (!array) return;
    for (let i = 0; i < visible; i += 1) {
      array[i * 3 + 1] = spots[i].y + Math.sin(t * 1.4 + spots[i].phase) * 0.35;
    }
    geometryRef.current.attributes.position.needsUpdate = true;
  });

  return (
    <points name="my-fireflies">
      <bufferGeometry ref={geometryRef}>
        <bufferAttribute attach="attributes-position" args={[positions, 3]} />
      </bufferGeometry>
      <pointsMaterial
        ref={materialRef}
        size={0.18}
        color="#b8ff5e"
        transparent
        depthWrite={false}
      />
    </points>
  );
}

Three habits to keep from this example:

  1. useModFrame, never raw useFrame - your frame cost shows up attributed in the debug panel, and repeated callback errors get contained instead of killing the render loop.
  2. Skip work at ~0 activity - daytime fireflies skip position updates.
  3. Stable name props (name="my-fireflies") - automated tests can find your objects in the scene graph.

4. Entry point ​

jsx
import { defineModClient } from '@vibelands/mod-sdk/client';
import { FirefliesLayer } from './FirefliesLayer';

export default defineModClient({ WorldLayer: FirefliesLayer });

Run vibelands serve . and wait for dusk in the hosted sandbox - or just verify in the console that the layer mounted:

js
window.__VIBELANDS_MODS__.status()   // { 'my-fireflies': { status: 'active' } }

Where to go next ​

  • Sounds at night? Using Assets + useModAudio (needs the audio permission).
  • Want chests players can open? That's replication - Tier B.

VibeLands Creator · Runtime API v2