Particles: visual effects
One config per effect, one ParticleSystem per emitter. A system owns exactly one emitter; for two effects you build two systems. Configs come from the tilemap-editor's particle JSON or are built by hand with ParticleSystemConfig.
QUICK START: ONE EFFECT, FIVE LINES
A particle effect has three pieces: a config (everything the effect looks like), a system (the runtime object), and aspawn area (the rect where new particles appear — passed to update() every frame). The engine does the rest:
from tilemap_parser import TilemapData
from tilemap_parser.runtime.particles import ParticleSystem
td = TilemapData.load("data/map.json", nodes_dir="data")
# 1. grab the effect's config from the map (emitters placed in the editor)
snow_node = next(n for n in td.particle_emitters if n.name == "snow")
# 2. build the system; maps with render_scale > 1: scale dimensionful
# fields once, and remember the scale for the area rect (step 3)
rs = td.render_scale
snow_cfg = snow_node.config
snow_cfg.apply_render_scale(rs)
snow = ParticleSystem(snow_cfg)
# 3. every frame: update() spawns inside the area, draw() blits with the camera
r = snow_node.rect
snow.update(dt, r.x * rs, r.y * rs, r.w * rs, r.h * rs)
snow.draw(screen, camera_x, camera_y, 1.0)The emitter's rect is raw editor pixels, not auto-scaled. Unlike AreaNode rects (which come pre-scaled), particle emitter rects need the same rs multiplier the config got. With render_scale = 1 the multiplication is a no-op, which is why examples that skip it still work.
Same shape when you bypass the map: parse_particle_file() returns the configs directly, and you supply the area rect yourself.
TWO WAYS TO SPEND THE AREA RECT
The area rect is simply the rect where new particles spawn. What it should be depends on the effect you want.
- Anchored to the map — an explosion at a fixed spot, a torch, a bullet spark. Use the emitter's rect from the editor node (a small, static rect in world coordinates), as in the quick start.
- Following the camera — full-screen effects like snow or mist that should cover the whole visible screen. Pass the on-screen rect each frame instead; particles keep spawning across the view and the effect follows the camera everywhere.
# the config is stored in the map; the area is the visible screen rect
rs = td.render_scale
snow_cfg = next(n for n in td.particle_emitters if n.name == "snow").config
snow_cfg.apply_render_scale(rs)
snow = ParticleSystem(snow_cfg)
# area = a rect on screen (top quarter of the view), moved with the camera.
# screen rect is already in effective pixels: no rs multiplication here.
snow.update(dt, cam.x, cam.y + HEIGHT // 4, WIDTH, HEIGHT // 2)
snow.draw(screen, cam.x, cam.y, 1.0)spark_cfg = next(n for n in td.particle_emitters if n.name == "spark").config
spark_cfg.apply_render_scale(rs)
spark_cfg.spawn_rate = 0 # no streaming; fire on demand
spark = ParticleSystem(spark_cfg)
# fire a fixed burst in world pixels: position and spread are yours to
# pick, rs-scaled if the map is scaled. A burst enters the area rect once,
# then dynamics take over.
spark.emit_burst(24, bullet.x, bullet.y, 8 * rs, 8 * rs)
# zero-area update: nothing new spawns, existing particles keep being animated
spark.update(dt, 0, 0, 0, 0)
spark.draw(screen, cam.x, cam.y, 1.0)Same config, same loop — only the rectangle changes. Map rect makes the effect stay in place; screen rect makes it travel with the camera; a zero rect means "no new particles, just finish the ones alive".
LOADING AND BUILDING
parse_particle_file() returns a list of ParticleSystemConfigs (one per effect in the file). Pick one, wrap it in a ParticleSystem, done.
from tilemap_parser import parse_particle_file, ParticleSystem
configs = parse_particle_file("data/particles/explosion.json")
explosion = ParticleSystem(configs[0])
# maps with render_scale > 1: scale dimensionful fields once
configs[0].apply_render_scale(render_scale)Everything about the effect lives in the config: spawn_rate, max_particles, lifetime_min/max, speed_min/max, direction + spread, gravity_x/y, start/end colors,start_scale/end_scale, alpha_fade, emission_shape and particle_shape. The valid values for the shape/fade fields are the module constants EMISSION_SHAPES, PARTICLE_SHAPES and ALPHA_FADE_MODES.
UPDATE AND DRAW: THE AREA RECT
update() needs the emitter's emission area: the rect where particles spawn — the two patterns above. Config emission_shape decides how the area is used: point / rect / circle / line. draw() needs the camera offset and zoom. This is the whole per-frame cost:
# update(dt, area_x, area_y, area_w, area_h) # spawn rect in world px
explosion.update(dt, 320.0, 240.0, 32.0, 32.0)
# draw(screen, offset_x, offset_y, zoom) # camera offset, not rect
ox, oy = camera.offset
explosion.draw(screen, ox, oy, zoom)BURSTS AND CONTINUOUS EMISSION
A burst fires a fixed count immediately; that's the explosion pattern. Continuous emission comes from the config's spawn_rate (particles per second, capped by max_particles) fed by update(), the torch pattern. Nothing to toggle at runtime; the config is the switch.
# explosion: 120 particles at once, anywhere in the 32x32 area
explosion.emit_burst(120, x, y, 32.0, 32.0)
# torch: config.spawn_rate > 0 and update() every frame emits steadilyRENDERERS
ParticleSystem.draw() internally calls SpriteBatchRenderer, the concrete renderer that caches shape textures, tints, scales and batches blits. The ParticleRenderer base class is abstract; you only meet it if you write your own renderer (implement prepare(particles, config) and draw(screen, offset_x, offset_y, zoom)). clear_texture_caches() frees the cached shape textures when you're done.
EDITOR-PLACED EMITTERS (NODES)
Emitters placed in the tilemap-editor come back as parsed nodes. Wrap each node in a ParticleEmitterNode to get its config and placement rect, then build the system, exactly as examples/particles/src/main.py does:
from tilemap_parser import parse_nodes_file
from tilemap_parser.runtime.particles import ParticleEmitterNode
for parsed in parse_nodes_file("data/map.nodes.json"):
if parsed.node_type != "particle_emitter":
continue
node = ParticleEmitterNode(parsed) # .config + .rect
ps = ParticleSystem(node.config)
# node.rect is raw editor coords: multiply by the map's render_scale
# (same rs the config got), like the quick start above
ps.update(dt, node.rect.x * rs, node.rect.y * rs, node.rect.w * rs, node.rect.h * rs)
ps.draw(screen, 0, 0, 1)If you already load the map with TilemapData.load(path, nodes_dir=...), the same emitters come pre-wrapped as td.particle_emitters; skip the manual wrapping.
ADVANCED: PARTICLE FIELDS — FOG, HAZE, DUST
The three modes above are for one-off or streaming particles. Fog, haze, and dust are different: they should already be there and only drift. That's what ParticleField is for — it creates the sheets once, then just moves them. Nothing is ever created or destroyed, so the fog never flickers and costs almost nothing per frame.
from tilemap_parser import ParticleField
# padded so sheets leave the screen before they wrap
fog = ParticleField(
area=(-80, -80, 960, 760),
color=(200, 205, 215),
alpha=14, # per-sheet strength (0-255)
density=1.0, # sheet count multiplier
direction=0, # drift direction, degrees (0 = right)
speed=(6, 14), # drift speed range, px/sec
quality="medium", # low / medium / high — budget dial
)
# then the normal loop; no particles are born or die
fog.update(dt)
fog.draw(screen)WHAT EACH ParticleField OPTION DOES
The quick answer for each option — what it changes, and the values it accepts. If an option says profile overrides, it only matters when you have not passed a profile.
| option | type | what it does |
|---|---|---|
area | (x, y, w, h) | The world rect where the fog lives. Sheets wrap at the edges, so pad it so sheets are off-screen before they wrap. Required. |
profile | FieldProfile | None | The layered tuning as plain data. Safer and easiest: pass FOG_PROFILE. None builds reasonable defaults from the size/speed/alpha options. Profile overrides those. |
shape | "circle" | "square" | "diamond" | "star" | "sparkle" | "smoke" | "heart" | "line" | "fog" | The sprite each sheet draws. "fog" is a flat, soft-edged square that tiles into continuous haze; "smoke" is rounder with a brighter middle. Profile overrides. |
color | (r, g, b) | Tint for every sheet. The end color is auto-darkened slightly. |
alpha | int 0-255 | How strong each sheet is. Only used when there is no profile. Profile overrides. |
global_alpha | float 0.0-1.0 | The master strength knob, multiplied into every layer's alpha. Assign to fade the whole effect live: field.global_alpha = 0.4. |
density | float > 0 | How many sheets there are. 1.0 = the default amount; 2.0 = twice that — and roughly twice the work. starting low and raising it only if the look is too thin. |
direction | float degrees | "random" | Where sheets drift: 0 = right, 90 = down, 180 = left, 270 = up. Or "random" — every sheet drifts its own way. |
speed | (min, max) px/sec | How fast sheets drift, as a range — each picks one. Varying speed between layers is what stops the fog looking like a grid. |
size | (min, max) px | Sheet size range. Only used when there is no profile. Profile overrides. |
spread | float 0-360 | How much wobble around direction; 0 = one straight drift angle. |
layers | int ≥ 1 | Depth layers for generic fields (no profile) — more = more depth, more work. Profile overrides. |
quality | "low" | "medium" | "high" | The performance budget: low = fewer sheets (cap 260), medium = default (cap 500), high = most (cap 800). It never removes layers — layer structure comes from the profile. Turn this down on weaker machines. |
ground_bias | bool | True keeps the nearest layer in the lower 65% of the area, so the fog reads as hugging the ground. |
render_scale | float > 0 | Scales sizes and speeds to match the map's render_scale. Pass map_data.render_scale. |
blend | int (pygame flag) |
Any non-zero flag except Useful non-zero choices: |
Live tuning: set_color((r, g, b)) retints in place (never touches alphas), set_density(x), set_motion(direction=90, speed=(2, 4)) and set_area((x, y, w, h)) rebuild the field so the change applies immediately. Read the result via field.layers — each layer has .name, .area and .system.
LAYERED FIELDS: DEPTH FROM PARALLEL FIELDS
One layer reads flat: same size, same speed, same alpha — a uniform haze. Run three stacked layers, each with its own size, speed and alpha, and the eye reads depth. The working recipe:
| layer | size | speed | alpha | role |
|---|---|---|---|---|
| far | largest | slowest | lowest | anchors the haze; reads as distance |
| mid | medium | medium | medium | the main volume |
| near | smallest | fastest | low | ground band; reads as proximity |
Wrap preserves each sheet's y-offset forever, so sheets that share a speed stay aligned as coherent rows or streaks — the giveaway that it is particles. Spreading speeds and sizes across layers is what dissolves that. The recipe below is a known-good fog; start from it and only touch the dials you care about.
from tilemap_parser import FOG_PROFILE, ParticleField
fog = ParticleField(
area=(-160, -90, 1600, 900),
profile=FOG_PROFILE, # the shipped fog tuning, as plain data
color=(200, 50, 80),
density=1.0,
global_alpha=1.0, # 0-1 strength scale
direction=0,
speed=(8, 8),
quality="medium",
ground_bias=True, # near layer uses the lower 65% of the area
)
fog.update(dt)
fog.draw(screen, 0, 0, 1)GENERIC CONTINUOUS FIELDS
Fog is only a preset. For dust, pollen, ash, or magic haze, use ParticleField without a profile — it still fills once and wraps forever, and you pick the shape, alpha, density, size and motion.
dust = ParticleField(
area=(-160, -90, 1600, 900),
shape="smoke",
color=(180, 150, 100),
alpha=10,
density=0.7,
direction=180,
speed=(3, 8),
size=(20, 45),
spread=45,
quality="low",
)
dust.update(dt)
dust.draw(screen)PROFILES: LAYER TUNING AS PLAIN DATA
FOG_PROFILE is a FieldProfile(name, presets): a named tuple of FieldLayerSpecs. Each spec is (name, size_min, size_max, speed_min_mul, speed_max_mul, alpha, coverage, ground_layer=False) — coverage is that layer's share of the area (2.0 = double the sheet area), speed_*_mul multiplies the field's speed range, and ground_layer=True pins the band to the lower 65%. Profiles are immutable data — copy them, never mutate them. profile.with_alpha(factor, name=None) returns a scaled copy (e.g. FOG_PROFILE.with_alpha(0.5, name="mist")) without touching the source. Here is the shipped fog, ready to copy:
| layer | size (px) | speed × base | alpha | coverage | band |
|---|---|---|---|---|---|
| far | 90-140 | 0.38-0.75 | 10 | 4.4 | full |
| mid | 60-95 | 0.62-1.12 | 16 | 2.67 | full |
| near | 40-65 | 1.00-1.75 | 10 | 1.85 | ground (lower 65%) |
HALF-RESOLUTION RENDERING AND SCENE GLOW
A few hundred big sheets still means per-pixel work every frame. Fog is a soft blur anyway, so render it into a half-resolution buffer and upscale once — a quarter of the blit area, visually identical. This is a rendering recipe, not a ParticleField responsibility:
mist_buffer = pygame.Surface((W // 2, H // 2), pygame.SRCALPHA)
mist_buffer.fill((0, 0, 0, 0))
mist.draw(mist_buffer, 0, 0, 0.5) # zoom = 1/2
# Two-step scale+blit: the dest-form smoothscale(src, size, dest)
# corrupts display surface pixels, so scale to a fresh surface first.
scaled = pygame.transform.smoothscale(mist_buffer, (W, H))
screen.blit(scaled, (0, 0)) # plain alpha compositeIf you want the fog to add light instead of just tint — glowing haze in a dark scene — swap the buffer to plain RGB and composite additively. The fog then brightens the scene where it lies instead of occluding it.
glow_buffer = pygame.Surface((W // 2, H // 2)) # no alpha channel
glow_buffer.fill((0, 0, 0))
mist.draw(glow_buffer, 0, 0, 0.5)
scaled = pygame.transform.smoothscale(glow_buffer, (W, H))
screen.blit(scaled, (0, 0), special_flags=pygame.BLEND_RGB_ADD)Alpha particles against black already store their premultiplied color, so the additive blit adds exactly the fog's light. Keep the field's ownblend at 0 for this — the additive behavior comes from the composite, not from the particles.
MANUAL FIELDS
ParticleField is a friendly wrapper around the primitive. If you need full control, build the contract yourself: wrap=True, spawn_rate=0, fill once with emit_field(), then update and draw normally.
cfg = ParticleSystemConfig(
name="mist", particle_shape="fog", emission_shape="rect",
wrap=True, spawn_rate=0,
particle_size_min=90, particle_size_max=140,
speed_min=6.0, speed_max=14.0,
start_color_a=14, end_color_a=14,
alpha_fade="none", max_particles=400)
ps = ParticleSystem(cfg)
ps.emit_field(0.6, -80, -80, 960, 760)
ps.update(dt, -80, -80, 960, 760)
ps.draw(screen, 0, 0, 1)