A lava lamp simulation served three ways: as ANSI half-blocks over SSH or
telnet, or as RGBA pixels on a <canvas> in the browser. One Rust
simulation, two output formats, one self-contained static binary.
script/serverThen from any other terminal:
ssh -p 2222 localhost # SSH transport
telnet localhost 5282 # telnet transport (no auth, default palette)
open http://localhost:8080/ # web transport (WASM in your browser)script/server does three things on each invocation:
script/setupβ generates a dev SSH host key in.dev/(gitignored) if missing.script/build-wasmβwasm-packs the engine towasm32-unknown-unknownsolava-webcan embed it.cargo run --release -p lavaβ runs the all-in-one binary that hosts every transport.
The telnet transport is the unauthenticated cousin of SSH: same engine, same
ANSI half-block frames, but no crypto and no username β so palette selection
(an SSH username, e.g. ssh uv@β¦) isn't available and every session uses the
default palette. Interactive keys (β/β, i, a, ?, q) still work.
If you just want the lamp without cloning or running an SSH client:
npx lava-watch # default palette
npx lava-watch uv # ultraviolet
npx lava-watch --ascii # start in ASCII mode (or hit `a` once running)
npx lava-watch --help # help infoSame engine, compiled to WebAssembly and shipped as a Node CLI β no network,
no SSH required. Requires Node β₯ 18. Source: npm/, build with
script/build-npm.
By SSH username or by URL path β same parser, same aliases.
ssh ultraviolet@localhost # or `uv@`, `blacklight@`
ssh pink@localhost # β bubblegum
ssh ice@localhost # β oceanhttp://localhost:8080/aurora
http://localhost:8080/toxic
http://localhost:8080/uv
Anything that doesn't parse falls back to classic. Once connected,
β / β cycles palettes (the new name flashes briefly in the
bottom-left badge), i inverts colors, a swaps in the ASCII
renderer ( .:-=+*#%@ density ramp instead of half-blocks), and
left-click anywhere on the lamp to heat that spot β nearby blobs
warm up and rise.
For the full palette list + aliases, two equivalent ways:
ssh help@localhost
ssh localhost -- --helpBoth print the colored help text and disconnect (no PTY required, no
connection slot consumed). The second form goes through exec_request,
so any -- <anything> falls back to the help doc β there are no other
commands to run.
All env vars; every transport reads what it needs from the same environment.
| Var | Type | Default | Used by | Description |
|---|---|---|---|---|
LAVA_SSH_PORT |
u16 | 2222 |
ssh | SSH listen port (falls back to the legacy LAVA_PORT if unset) |
LAVA_HOST_KEY |
string | (required) | ssh | Contents of an OpenSSH-format private host key (not a path) |
LAVA_HOST_KEY_PASSWORD |
string | (none) | ssh | Passphrase for LAVA_HOST_KEY if it's encrypted |
LAVA_TELNET_PORT |
u16 | 5282 |
telnet | Telnet listen port (5282 = "LAVA" on a phone keypad) |
LAVA_MAX_CONN_TIME |
u64 | 300 |
ssh, telnet | Hard session timeout, in seconds |
LAVA_MAX_PER_IP |
usize | 3 |
ssh, telnet | Concurrent connections per IP (each transport counts separately) |
LAVA_SPEED |
f32 | 0.8 |
ssh, telnet | Simulation speed multiplier (1.0 = engine "natural" rate, lower = slower) |
LAVA_WEB_PORT |
u16 | 8080 |
web | HTTP listen port |
RUST_LOG |
string | lava=info,β¦ |
all | tracing-subscriber filter |
Logs are pretty-printed when stdout is a TTY and JSON otherwise. SSH events
include peer (IP:port), cols/rows, term (client $TERM), banner
(client SSH version), palette, duration_secs, and a structured reason
(client_exit, timeout, disconnect, write_failed). Telnet events carry
the same peer, palette, duration_secs, and reason fields.
ββββββββββββββββββββββββββββββ
β lava-engine β
β sim Β· palette Β· session β
β ββββββββββββ βββββββββββ β
β β term β β pixels β β
β β (ANSI) β β (RGBA) β β
β ββββββββββββ βββββββββββ β
ββββ¬ββββββββ¬βββββββββββ¬βββββββ
β β β
ββββββββββ β ββββββββββ
βΌ βΌ βΌ
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β lava-ssh β β lava-telnet β β lava-wasm β
β (russh) β β (raw TCP) β β(wasm-bindgen)β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β β β
β β βΌ
β β ββββββββββββββββ
β β β lava-web β axum static server,
β β β β embeds wasm bundle
β β ββββββββ¬ββββββββ
βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β lava β single static binary,
β tokio::try_join!(ssh, telnet, web) β runs every server
βββββββββββββββββββββββββββββββββββββββββββ
The two terminal transports (lava-ssh, lava-telnet) are thin: each just
adapts its byte channel (an SSH channel handle, a TCP socket) into a
FrameSink and hands off to lava-term, which owns the shared frame loop,
the per-IP connection tracker, and the timing/size constants.
The browser runs the simulation client-side via WebAssembly. The whole
web bundle (HTML, JS, WASM) is include_bytes!'d into the binary.
ANSI / terminal output:
use lava_engine::{Palette, Session};
let mut session = Session::new(80, 30, Palette::Bubblegum);
let mut frame = Vec::new();
loop {
session.tick(1.0 / 30.0);
frame.clear();
session.render(&mut frame);
// write `frame` bytes to a PTY, stdout, β¦
}RGBA pixel output (same engine, different sink):
session.render_rgba(&mut frame);
// frame is `width * height * 4` bytes β feed to a canvas, PNG encoder, β¦lava/
βββ crates/
β βββ lava/ single-binary entrypoint (ssh + telnet + web)
β βββ lava-engine/ simulation, palettes, term + pixels renderers, Session
β βββ lava-ssh/ SSH server library (russh)
β βββ lava-telnet/ telnet server library (raw TCP, minimal IAC handling)
β βββ lava-term/ shared frame loop, per-IP tracker + constants (ssh + telnet)
β βββ lava-wasm/ wasm-bindgen wrapper exposing the canvas + Node CLI API
β βββ lava-web/ axum static-asset server library
βββ npm/ lava-watch CLI β lava-wasm wrapped as an npx-able Node bin
βββ script/
βββ setup generate dev host key (idempotent)
βββ build-wasm wasm-pack build β static bundle (web target)
βββ build-npm wasm-pack build β npm/pkg (nodejs target)
βββ server setup + build-wasm + run unified binary
βββ lava-watch build-npm + run the CLI (dev-loop `npx lava-watch`)
βββ test cargo fmt --check + clippy + test
MIT