New

dot-fx

Visual effects as documents — pooled, frame-budgeted, quality-tiered and distance-culled, with a decal ring and camera shake computed as a value the game applies. An effect never changes the simulation.

dot-fx is visual effects as documents rather than scattered instantiate() calls: pooled, budgeted, quality-tiered, distance-culled and safe to drop.

Requires dot-core.

One invariant

An effect never changes the simulation. It is a picture of something that already happened, and that is what makes every refusal here safe: a frame budget may drop one, a quality tier may refuse one, a distance cull may skip one, and a client whose content pack is still downloading simply does not see it — and in none of those cases do two machines disagree about anything that matters.

So dot-combat owns damage, dot-effects owns what happens over time, and this owns what it looks like.

Usage

var fx := DotFxManager.new()
fx.catalogue = my_catalogue
fx.world_ref = DotNodeRef.of_path(world.get_path())
add_child(fx)
fx.setup()

fx.viewer_position = camera.global_position     # once a frame; culling needs it
fx.spawn(&"muzzle_flash", muzzle.global_transform)
fx.spawn_decal(&"bullet_hole", hit)
fx.shake_at(&"grenade", where)                  # scaled by distance
fx.flash(&"hurt")                               # capped and rate limited
fx.advance(delta)

spawn_2d sits beside spawn rather than widening its signature: a caller holding a Vector2 is a different caller.

A game that plays the beacon’s ping itself

A game that plays the ping through its own sound catalogue sets marker.ping_enabled = false (API level 3), before add_child or followed by build(). No voice is built, and advance() still returns true on each pulse so the game plays its own sound from that. Without it the ping would be heard twice.

A low quality tier is missing effects

min_quality gates whether an effect exists, not how detailed it is. Halving the particle count on the effect that is killing the frame does not save the frame; not spawning it does. frame_budget is the same idea per frame — a grenade landing in a crowd cannot cost more than one frame has. A request over the budget displaces lower-priority effects started in the same frame, lowest first and only if that frees enough, never one already on screen; a request the budget refuses retires nothing.

Camera shake is a value

fx.advance(delta)
camera.position = base + fx.shake.offset()
camera.rotation.z = base_roll + fx.shake.roll()

It computes a displacement and touches no camera, so one implementation serves a 3D rig, a 2D one, a spectator camera and a headless suite. Two details make it read as shake rather than as a fault: trauma is squared, because a linear falloff stops abruptly, and the motion is sampled noise rather than a random number per frame, which is jitter at the frame rate and looks different at 60 and 144 fps.

Decals are a ring

An unbounded decal list grows with the round rather than with the number of players, which makes it invisible in testing and fatal at minute forty. max_decals recycles the oldest, and lowering it applies at the next decal or the next advance().