An animated ASCII wave simulator for the terminal, in 24-bit color. Single file, pure Python standard library, no dependencies.
python3 waves.py
Press ? for the key bindings, q to quit.
Cycle through them live with m.
| ocean | ripples |
ocean — open-water swell |
ripples — droplet impacts |
| interference | spiral |
interference — point sources |
spiral — a rotating rotor wave |
oceansums a spectrum of directional sine waves under a deep-water dispersion relation, so long swell outruns short waves. Crests are sharpened, troughs flattened, and a short-wavelength component adds chop.ripplesdrops stones on calm water. Each impact sends out a wave packet — a gaussian envelope riding an expanding ring — that decays with age.interferenceholds several point sources at fixed positions and lets their wavefronts beat against each other.spiralwinds the phase around the center to make rotating arms.
| Key | Action |
|---|---|
q / Esc |
quit |
space |
pause |
m / M |
next / previous scene |
c / C |
next / previous palette |
a |
next character ramp |
r |
randomize the current scene |
d |
disturb — drop a stone, gust the wind, move sources |
+ / - |
speed |
[ / ] |
amplitude |
h |
toggle the status bar |
? |
help |
-m, --mode ocean | ripples | interference | spiral
-p, --palette ocean | sunset | neon | magma | ice | matrix | ember | mono
-a, --ramp classic | ascii | blocks | shade | dots | sea
--color truecolor | 256 | none (auto-detected; NO_COLOR is honored)
--fps target frame rate (default 30)
-s, --speed time multiplier
--amplitude wave height multiplier
--seed reproducible wave sets
--width override the terminal width
--height override the terminal height
--frames N render N frames and exit, on a fixed timestep
--no-hud hide the status bar
python3 waves.py -m ripples -p ice -a blocks python3 waves.py -m spiral -p magma --speed 0.4 --no-hud
--frames renders a fixed number of frames on a fixed timestep and exits, so
batch output is byte-identical regardless of how fast the machine is. Combined
with --seed it makes runs reproducible, which is how the images above were
generated.
The height field is a sum of sinusoids evaluated per cell, quantized into 48
levels. Each level indexes a precomputed table of (ANSI escape, glyph) pairs,
so color and character both fall out of one lookup.
Two things keep it fast enough to run in pure Python at full-screen sizes:
- Integer phase accumulation. Sines come from a 4096-entry lookup table
indexed by an integer phase. Because that index is linear in the phase,
phases add as plain integers, and everything static about a wave —
k·xper column,k·rper cell for a point source — is precomputed once at resize time. Each cell then costs an integer add, a mask, a table lookup and a multiply. - Run-length color. An escape sequence is emitted only when the level changes from the previous cell, which cuts about a fifth of the output bytes.
A ×ばつ55 frame takes roughly 8 ms to compute and render, so the 30 fps default has plenty of headroom.
Two details worth knowing if you read the source:
- Terminal cells are about twice as tall as they are wide, so the vertical axis is scaled by 2 to keep wavefronts circular rather than elliptical.
time.sleep()overshoots by several milliseconds on some platforms (~8 ms on macOS). The frame pacer tracks that error and asks for correspondingly less on the next frame; without it a 30 fps target lands at about 24 fps.
Python 3.8 or newer, and a terminal that understands ANSI escapes.
Color depth is detected from COLORTERM and TERM, and NO_COLOR is honored;
--color overrides the guess. Over a slow link, prefer --color 256 — a
full-screen truecolor frame is around 50 KB, while 256-color escapes are much
shorter.
Interactive keys need a POSIX terminal (macOS, Linux, BSD, WSL). On native
Windows the animation renders, but key handling is unavailable and Ctrl-C
is the way out.
tools/capture.py rasterizes frames by rendering each frame's text once as a
white-on-black mask and multiplying it by a per-cell color image, so the images
use real terminal glyphs rather than an approximation. It needs ImageMagick,
and ffmpeg for the GIF. See the comments at the top of that file.
MIT — see LICENSE.