Skip to content

Tier A: Decorative Plugin CLIENT-ONLY

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

Client code guide

This walkthrough uses the same React/R3F client-surface model available to reviewed public marketplace plugins through entries.client. Workspace plugins 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 usePluginConfig().

2. Deterministic placement on real terrain

jsx
import { useMemo } from 'react';
import {
  useDeterministicRng,
  usePluginConfig,
  useTerrainSampler,
  useWorldInfo,
} from '@vibelands/plugin-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 = usePluginConfig();

  return useMemo(() => {
    const count = Math.round(BASE_COUNT * (config.density ?? 1));
    const radius = terrain.size * 0.4;
    const spots = [];
    while (spots.length < count) {
      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 - multiplayer immersion dies quietly.

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 usePluginFrame.

jsx
import { useMemo, useRef } from 'react';
import {
  useAtmosphereFrame,
  useGraphicsQuality,
  usePluginFrame,
} from '@vibelands/plugin-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]);

  usePluginFrame((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. usePluginFrame, 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 cost zero.
  3. Stable name props (name="my-fireflies") - automated tests can find your objects in the scene graph.

4. Entry point

jsx
import { definePluginClient } from '@vibelands/plugin-sdk/client';
import { FirefliesLayer } from './FirefliesLayer';

export default definePluginClient({ 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_PLUGINS__.status()   // { 'my-fireflies': { status: 'active' } }

Where to go next

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

VibeLands Creator - Plugin SDK apiVersion 2