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
{
"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
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.
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:
usePluginFrame, never rawuseFrame- your frame cost shows up attributed in the debug panel, and repeated callback errors get contained instead of killing the render loop.- Skip work at ~0 activity - daytime fireflies cost zero.
- Stable
nameprops (name="my-fireflies") - automated tests can find your objects in the scene graph.
4. Entry point
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:
window.__VIBELANDS_PLUGINS__.status() // { 'my-fireflies': { status: 'active' } }Where to go next
- Sounds at night? Using Assets +
usePluginAudio(needs theaudiopermission). - Want chests players can open? That's replication - Tier B.