{
  "id": "holezone",
  "name": "HoleZone (golf)",
  "version": 1,
  "updated": "2026-10-10",
  "notes": "Machine-readable target for designing hz-particles effects meant for the HoleZone golf game (~/project/golf). Every value below mirrors the game's runtime code (sources listed per field). Editor units are scaled by the per-type 'gameScale' before the game renders them, so an effect authored at 1.0 in the editor is gameScale meters in the game.",
  "units": "meters",
  "world": {
    "ballRadius": 0.02135,
    "ballDiameter": 0.0427,
    "cupRadius": 0.07,
    "cupDepth": 0.07,
    "gridCell": 1.0,
    "shotMaxSpeed": 6.5,
    "gravity": { "default": 9.81, "moon": 1.62, "abyss": 3.0 },
    "maxPlayers": 6,
    "sources": [
      "HoleZone-core/packages/golfysic/src/constants.ts",
      "HoleZone-backend/src/game/cluster/course/bots/sim.go (cup)",
      "HoleZone-core contracts/src/physics.ts (gravity presets)"
    ]
  },
  "camera": {
    "fov": 45,
    "near": 0.1,
    "far": 1000,
    "followDistance": 1.0,
    "followHeight": 0.35,
    "holeOverviewDistance": 1.5,
    "holeOverviewHeight": 0.5,
    "holeOverviewHeightIsExtra": true,
    "holeOverviewLookUp": 0.4,
    "holeOverviewNote": "Hole overview pose (director.ts cameraPlacement): camera at hole + followHeight + holeOverviewHeight = 0.85 m up, holeOverviewDistance back, looking at hole + holeOverviewLookUp (0.4 m): the cup sits in the lower half and about 1 m above it is in frame.",
    "source": "HoleZone-core/packages/renderer-three/src/camera/types.ts (GAME_CAMERA_FOLLOW, DEFAULT_CAMERA_CONFIG hole overview) + director.ts cameraPlacement"
  },
  "quality": {
    "tiers": { "high": 1.0, "medium": 0.5, "low": 0.25 },
    "appliesTo": ["particleCount", "emissionRate"],
    "libraryDefaultMaxParticles": 10000,
    "source": "QualityManager.ts + renderer-three/src/fx/particleDensity.ts (withParticleDensity, withFittedParticleBudget)"
  },
  "types": {
    "trail": {
      "groundOccludes": true,
      "label": "Ball trail (cosmetic item, kind=trail)",
      "gameScale": 0.5,
      "gameScaleSource": "GameScene.tsx: scalePreset(preset, cfg.scale ?? 0.5); item fx.scale overrides it",
      "anchor": "moving",
      "anchorDetail": "addMovingEmitter following the ball every frame; paused (getPosition -> null) while the ball is still, restarted from its first particle on the next roll. Created once at hole load and kept all match.",
      "lifetimeModel": "infinite-continuous",
      "lifetimeDetail": "Continuous systems are forced to emissionDurationInfinite by liteTrailPreset; a finite window would expire before the first shot. burstMode/oneOnlyMode keep their own timing.",
      "bloom": "forced-off",
      "bloomDetail": "liteTrailPreset forces bloomEnabled=false (3 systems x 6 balls = ~100 full-screen passes). Design the look without bloom.",
      "orientToDirection": "per item (fx.orientToDirection); yaw-only, visible only on asymmetric shapes",
      "concurrent": 6,
      "budget": { "maxLiveParticlesPerEffect": 600, "maxSystems": 3 },
      "trailCaps": {
        "nonHighTier": { "emissionTrailMaxPoints": 48, "emissionTrailSegments": 8 }
      },
      "visualSize": {
        "minParticleMeters": 0.004,
        "maxParticleMeters": 0.15,
        "maxExtentMeters": 1.2,
        "calibration": "shipped trail catalog (2026-08): ribbons 6-15 cm, note/star sprites 6-12 cm"
      },
      "viewDistances": [1.06],
      "viewDistancesSource": "chase camera: followDistance 1 m back, followHeight 0.35 m up (1.06 m to the ball)",
      "hints": [
        "Read at 1 m from the camera behind a 4.3 cm ball: particles and ribbons of 1 to 3 ball diameters (4-13 cm) read best.",
        "Keep the emission shape tight around the ball (radius <= 2 ball radii after scale).",
        "Lifetime 0.3 to 1.5 s: longer trails smear across the whole screen at 6.5 m/s."
      ]
    },
    "splash": {
      "groundOccludes": true,
      "label": "Hole-in splash (cosmetic item, kind=splash)",
      "gameScale": 0.15,
      "gameScaleSource": "playerCustomizationStore.ts: c.fx?.scale ?? 0.15; SplashEffect.tsx applies scalePreset when scale != 1",
      "anchor": "point",
      "anchorDetail": "Spawned at the cup position plus item fx.offset, pooled (addPooledFxGroup), one per player.",
      "lifetimeModel": "one-shot",
      "lifetimeDetail": "Hidden after fx.duration (default 4.5 s). Everything should be done by then; do not rely on looping.",
      "defaultDuration": 4.5,
      "originBelowSurface": 0.06,
      "originBelowSurfaceSource": "holePosition is written at green surface - 0.06 (ozone.go; renderer-three ball/ballSink.ts DECALAGE_CAPTURE_Y); the splash spawns there, 6 cm inside the cup",
      "bloom": "allowed",
      "concurrent": 6,
      "budget": { "maxLiveParticlesPerEffect": 2500, "maxSystems": 5 },
      "visualSize": { "minParticleMeters": 0.005, "maxParticleMeters": 0.25, "maxExtentMeters": 2.0 },
      "viewDistances": [1.72],
      "viewDistancesSource": "hole overview camera: 1.5 m back, 0.85 m up (1.72 m to the cup)",
      "hints": [
        "Seen from the hole overview camera (1.5 m back, 0.85 m up, aimed 0.4 m above the cup): the cup is 14 cm wide, in the lower half of the frame; about 1 m above it is still in frame.",
        "The effect origin is 6 cm BELOW the green surface (inside the cup): anything that stays lower than that is hidden by the green, except straight above the hole.",
        "A burst rising 0.3 to 0.8 m above the cup reads well; above about 1 m it leaves the hole overview frame.",
        "Front-load the action in the first second; fade out before 4.5 s."
      ]
    },
    "map": {
      "label": "Map / ambient FX placed in the course (studio fx bank)",
      "gameScale": 1.0,
      "gameScaleSource": "fx.json placement {preset, pos, scale, rot}; scale is per placement (author 1:1 in meters)",
      "anchor": "world",
      "anchorDetail": "Placed in world coordinates on the 1 m voxel grid; same-preset placements are merged into one group (fusionEmetteurs.ts).",
      "lifetimeModel": "infinite-continuous",
      "lifetimeDetail": "Plays for the whole hole: use emissionDurationInfinite or a looping clock.",
      "bloom": "allowed-sparingly",
      "concurrent": 30,
      "budget": { "maxLiveParticlesPerEffect": 1500, "maxSystems": 4 },
      "visualSize": { "minParticleMeters": 0.01, "maxParticleMeters": 1.5, "maxExtentMeters": 20 },
      "viewDistances": [1.5, 5, 15],
      "viewDistancesSource": "no single distance: from the hole overview (1.5 m) to across the course",
      "hints": [
        "Author in real meters: one grid cell = 1 m = one voxel block.",
        "Many placements of the same preset are cheap (merged); many distinct presets are not.",
        "emissionRotation has no effect on point-shaped systems: orient with a velocity override instead."
      ]
    },
    "surface": {
      "groundOccludes": true,
      "label": "Surface enter / impact FX (sand-enter, lava-splash, water-ricochet)",
      "gameScale": 1.0,
      "gameScaleSource": "surfaces.json client.enterFx; WaterSplashFX / SurfaceFxPools spawn without scale",
      "anchor": "point",
      "anchorDetail": "Spawned at the ball contact point; pooled (water ricochet: 2 slots, 250 ms throttle).",
      "lifetimeModel": "one-shot",
      "lifetimeDetail": "Short impact, about 2 s max.",
      "defaultDuration": 2.0,
      "pool": { "slots": 2, "throttleMs": 250, "source": "WaterSplashFX.tsx FX_SLOTS / FX_THROTTLE_MS: one pool per preset; a contact restarts the oldest slot from its first frame, so with contacts at the throttle rate an occurrence is only sure to play slots x throttle" },
      "bloom": "avoid",
      "concurrent": 6,
      "budget": { "maxLiveParticlesPerEffect": 300, "maxSystems": 2 },
      "visualSize": { "minParticleMeters": 0.003, "maxParticleMeters": 0.06, "maxExtentMeters": 0.5 },
      "viewDistances": [1.06],
      "viewDistancesSource": "chase camera: the ball (and its impact) 1.06 m away",
      "hints": ["Ball-sized event: droplets/grains of 3 to 15 mm, spray no higher than 0.3 m.", "Must be embedded in the .ozone to play (no fallback)."]
    }
  },
  "renderer": {
    "host": "initHzFxOverlay(target, { getSceneDepth: makeThreeSceneDepth(gl), autoRespawn: false })",
    "webgl2": "hz-particles FX do not render on the WebGL2 fallback (open TODO in golf features/webgl2-fx-hz-particles.md).",
    "loading": "Every effect is built during the loading screen; anything created mid-play stalls the frame (compute pipelines compile on first use)."
  }
}
