Overview
Cellua is a pure, expression-oriented language for pixel art programs. You write one function — pixel(x, y, t) — and the runtime evaluates it for every pixel every frame.
pixel(x, y, t) = sin(x * 0.1 + t) > 0 ? WHITE : BLACK
Because pixel is a pure function with no side effects, every pixel is computed independently. There are no loops, no mutable state, no imperative statements — only expressions that map coordinates to colors.
Syntax
A Cellua program is a sequence of top-level items:
palette = NAME -- optional; one of PICO8 | GAMEBOY | CGA name = expr -- constant definition name(a, b, ...) = expr -- function definition pixel(x, y, t) = expr -- required entry point
All identifiers are case-sensitive. Color/palette names are UPPER_SNAKE_CASE; everything else is lower_snake_case. Comments start with -- and run to end-of-line.
Definitions may appear in any order — the evaluator resolves dependencies automatically.
Types
There are exactly three types. You never write them; the type checker infers everything.
| Type | Representation | Examples |
|---|---|---|
number | 64-bit float (f64) | 3.14, -1, x / WIDTH, sin(t) |
color | RGBA u8×4 | RED, #ff004d, palette_nearest(r,g,b) |
bool | true / false | x > 5, a == b, not flag |
The pixel function may return any type; the runtime converts to color: a number in [0,1] becomes greyscale; a bool becomes WHITE/BLACK.
Arithmetic operands must be numbers. Logic operands must be bools. Ternary branches must share a type. Everything else is a compile-time type error.
Built-in Bindings
| Name | Type | Value |
|---|---|---|
x | number | pixel column, 0 = left edge |
y | number | pixel row, 0 = top edge |
t | number | elapsed seconds since program start |
WIDTH | number | canvas width in pixels |
HEIGHT | number | canvas height in pixels |
PI | number | π ≈ 3.14159 |
TAU | number | 2π ≈ 6.28318 |
E | number | e ≈ 2.71828 |
Coordinate system: origin (0,0) is top-left. x increases right, y increases down (screen coordinates). To get a +y-up math frame, use cy - y instead of y - cy when computing vectors.
Time: t advances at 1.0 s⁻¹. Common patterns:
sin(t) -- oscillates [-1,1], period 2π s sin(t * f) -- frequency f rad/s; period 2π/f s sin(t) * 0.5 + 0.5 -- oscillates [0,1] t % 1.0 -- sawtooth ramp 0→1, period 1 s floor(t) -- integer step every second
These bindings are available everywhere: in pixel, in helper functions, and in constants.
Definitions
Constants and functions
speed = 1.5 -- constant cx = WIDTH / 2 -- uses built-in binding radius = sin(t) * 10 + 30 -- animated: t is always in scope r = sqrt((x-cx)*(x-cx) + (y-cy)*(y-cy)) -- pixel-dependent f = floor(t / 4) % 7 -- filter index cycling every 4 s ring(x, y, r1, r2) = -- helper function dist(x, y, cx, cy) > r1 and dist(x, y, cx, cy) < r2
A "constant" is just a zero-param function, evaluated fresh for every pixel. Because evaluation always happens inside a pixel context, zero-param definitions can freely reference x, y, and t.
Convention: helper functions that receive coordinates conventionally declare x, y, t as explicit parameters. This keeps call sites readable. You can omit them and reference them freely, but explicit params make the data flow obvious.
Parameter shadowing
Function parameters shadow any outer binding with the same name. If you define r = 20 at top-level and then f(r) = r * 2, the r inside f is the parameter.
Forward references
Definitions may reference names declared later in the file; the evaluator resolves evaluation order via a dependency graph.
Cyclic definitions
Direct or indirect self-reference is an error: a = b + 1; b = a - 1 → "'a' refers to itself". The exception is pixel using self[dx,dy] (neighbor access), which reads the previous frame by design.
Operators
| Category | Operators | Operand types | Result |
|---|---|---|---|
| Arithmetic | + - * / % ^ | number, number | number |
| Comparison | == != < <= > >= | number, number | bool |
| Logic | and or not | bool (bool) | bool |
| Ternary | cond ? a : b | bool; T, T | T |
| Match arm | | guard -> expr | bool; any | arm type |
| Set test | v in {a, b, c} | number; numbers | bool |
Precedence (high → low): unary - not → ^ → * / % → + - → comparisons → and → or → ? :.
Division: / is always floating-point. 3 / 2 = 1.5. Use floor(a / b) for integer division.
Power: ^ maps to pow(a, b). 2^10 = 1024.
Equality: == uses a tolerance of 1e-9 to account for floating-point rounding.
Control Flow
Ternary
condition ? value_if_true : value_if_false
Both branches must have the same type. Nesting is fine: a ? b : c ? d : e.
Match
Guards are tested top-to-bottom; the first matching arm wins.
pixel(x, y, t) = | dist(x, y, cx, cy) < 10 -> RED | dist(x, y, cx, cy) < 20 -> ORANGE | dist(x, y, cx, cy) < 30 -> YELLOW | _ -> DARK_BLUE -- wildcard (required if non-exhaustive)
Without a wildcard | _ -> ... arm, a non-matching pixel is a runtime error (renders magenta).
Set membership
floor(x / 8) % 3 in {0, 2} -- true for bands 0 and 2
Neighbor Access
self[dx, dy]
Reads the value of pixel (x+dx, y+dy) from the previous frame. Out-of-bounds indices wrap toroidally (the canvas is a torus). Returns a number in [0,1] representing the previous pixel's luminance.
This is the mechanism for cellular automata: each pixel sees its neighbors' previous state.
-- 4-neighbor sum for a CA
n4(x, y) = self[0,-1] + self[0,1] + self[-1,0] + self[1,0]
-- Conway: survive on 2-3, born on 3
pixel(x, y, t) =
| self[0,0] == 1 and n4(x,y) in {2,3} -> 1
| self[0,0] == 0 and n4(x,y) == 3 -> 1
| _ -> 0
On the first frame, the previous frame is all black (zero luminance).
Palettes
Select a palette with palette = NAME. Only the active palette's color names are in scope.
PICO8 (default) — 16 colors
| BLACK | DARK_BLUE | DARK_PURPLE | DARK_GREEN |
| BROWN | DARK_GREY | LIGHT_GREY | WHITE |
| RED | ORANGE | YELLOW | GREEN |
| BLUE | LAVENDER | PINK | PEACH |
GAMEBOY — 4 colors
DARKEST DARK LIGHT LIGHTEST (green monochrome)
CGA — 4 colors
BLACK CYAN MAGENTA WHITE
Hex literals
#ff004d -- RGB hex, any value, not restricted to palette
Computed colors
palette_nearest(r, g, b) -- snaps to closest palette entry by RGB distance
Trig & Exponential
| Function | Returns | Notes |
|---|---|---|
sin(a) | [-1, 1] | period 2π; sin(0)=0, sin(π/2)=1 |
cos(a) | [-1, 1] | period 2π; cos(0)=1 |
tan(a) | ℝ | undefined at ±π/2 + nπ |
asin(a) | [-π/2, π/2] | inverse sin; domain [-1,1] |
acos(a) | [0, π] | inverse cos; domain [-1,1] |
atan2(y, x) | (-π, π] | angle of vector (x,y) from +x axis |
exp(a) | (0, ∞) | eᵃ |
log(a) | ℝ | natural log ln(a); a must be > 0 |
log2(a) | ℝ | log base 2 |
theta = atan2(y − cy, x − cx).To map a sine wave to a 0-1 range: sin(a) * 0.5 + 0.5. To control amplitude and offset: sin(a) * amp + center.
Math
| Function | Returns | Notes |
|---|---|---|
sqrt(a) | ≥ 0 | √a; NaN for a < 0 → treated as 0 |
abs(a) | ≥ 0 | |a| |
sign(a) | {-1, 0, 1} | signum |
floor(a) | integer-valued f64 | ⌊a⌋ rounds toward −∞ |
ceil(a) | integer-valued f64 | ⌈a⌉ rounds toward +∞ |
round(a) | integer-valued f64 | nearest integer (half rounds up) |
fract(a) | [0, 1) | fractional part: a − ⌊a⌋ |
pow(a, b) | ℝ | aᵇ; same as a ^ b |
min(a, b) | ℝ | minimum |
max(a, b) | ℝ | maximum |
mod(a, b) | [0, b) | floating-point remainder; mod(-1,8)=-1 (use wrap for positive) |
Geometry
| Function | Formula |
|---|---|
dist(x,y,cx,cy) | √((x−cx)²+(y−cy)²) — Euclidean distance |
dist_sq(x,y,cx,cy) | (x−cx)²+(y−cy)² — avoids sqrt; compare to r² |
dist_manhattan(x,y,cx,cy) | |x−cx|+|y−cy| — diamond contours |
dist_chebyshev(x,y,cx,cy) | max(|x−cx|, |y−cy|) — square contours |
Use dist_sq instead of dist when you only need comparisons: dist_sq(x,y,cx,cy) < r*r avoids a square root.
Polar coordinates
cx = WIDTH / 2 cy = HEIGHT / 2 r(x, y) = dist(x, y, cx, cy) theta(x, y) = atan2(y - cy, x - cx) -- angle in (-π, π]
Interpolation
| Function | Formula | Notes |
|---|---|---|
clamp(v, lo, hi) | max(lo, min(hi, v)) | pins v to [lo, hi] |
mix(a, b, t) | a + (b−a)·t | linear interpolation; t=0→a, t=1→b |
smoothstep(lo, hi, x) | S((x−lo)/(hi−lo)) | S(t)=3t²−2t³; smooth 0→1 ramp |
mix_color(a, b, t) | per-channel mix | blend two colors; t∈[0,1] |
Combine mix and smoothstep for anti-aliased edges:
-- Soft circle: smooth 2-pixel edge d = dist(x, y, cx, cy) pixel(x, y, t) = mix_color(WHITE, DARK_BLUE, smoothstep(r - 1, r + 1, d))
Noise & Fractal Brownian Motion
| Function | Returns | Notes |
|---|---|---|
noise(x, y) | [-1, 1] | 2D gradient noise (Perlin-style) |
noise(x, y, z) | [-1, 1] | 3D variant; use z=t for animated noise |
fbm(x, y, octaves) | ≈ [-1, 1] | fractal Brownian motion, 2D |
fbm(x, y, t, octaves) | ≈ [-1, 1] | 4-arg form, animated |
fbm(x, y, n) = Σᵢ₌₀ⁿ⁻¹ 0.5ⁱ · noise(2ⁱx, 2ⁱy)
Low octaves contribute large-scale structure; high octaves add fine detail. At 1 octave, fbm = noise. At 6 octaves, the result resembles a natural texture (clouds, terrain, fire).
Practical usage — scale coordinates to control feature size:
-- Large blobs (low frequency) noise(x * 0.01, y * 0.01) -- Fine texture (high frequency) noise(x * 0.2, y * 0.2) -- Animated noise field noise(x * 0.05, y * 0.05, t * 0.5) -- Terrain height map fbm(x * 0.015, y * 0.015, 6)
Voronoi
| Function | Returns | Notes |
|---|---|---|
voronoi(x, y, n) | ≥ 0 | distance to nearest of n seeds |
voronoi_id(x, y, n) | [0, n) | index of nearest seed (integer-valued f64) |
voronoi returns the Euclidean distance to that seed;
voronoi_id returns the seed's index.
Seeds move over time if you include t in the input coordinates.
-- Color each region by its seed index pixel(x, y, t) = | voronoi_id(x, y, 6) == 0 -> RED | voronoi_id(x, y, 6) == 1 -> ORANGE | voronoi_id(x, y, 6) == 2 -> YELLOW | voronoi_id(x, y, 6) == 3 -> GREEN | voronoi_id(x, y, 6) == 4 -> BLUE | _ -> LAVENDER -- Moving seeds pixel(x, y, t) = voronoi(x + sin(t)*10, y + cos(t*0.7)*10, 8) / 50
Tiling
| Function | Formula | Use for |
|---|---|---|
wrap(x, w) | ((x % w) + w) % w | modular tiling, always ≥ 0 |
mirror(x, w) | fold x in period w | mirror-repeat tiling |
tile_x(x, w) | wrap(x, w) | horizontal tile coordinate |
tile_y(y, h) | wrap(y, h) | vertical tile coordinate |
mod(x, w) can return negative values when x is negative. wrap(x, w) always returns a value in [0, w), making it safe for tile coordinates and texture lookups.-- 16×16 tile pattern tx = tile_x(x, 16) ty = tile_y(y, 16) pixel(x, y, t) = noise(tx * 0.1, ty * 0.1) * 0.5 + 0.5
Bitwise
| Function | Operation |
|---|---|
band(a, b) | a AND b (bitwise) |
bor(a, b) | a OR b (bitwise) |
bxor(a, b) | a XOR b (bitwise) |
bshl(a, n) | a << n (left shift) |
bshr(a, n) | a >> n (right shift) |
All arguments are truncated to i64 before the operation; the result is cast back to f64. Works correctly on any integer-valued number.
-- XOR fractal (classic demoscene) pixel(x, y, t) = bxor(floor(x + t * 8), floor(y)) % 32 / 32.0
Color Functions
| Function | Signature | Returns |
|---|---|---|
palette_nearest | (r, g, b) → color | closest palette entry by RGB distance |
luminance | (color) → number | perceptual luminance ∈ [0, 1] |
mix_color | (color, color, t) → color | linear blend; t∈[0,1] |
-- Palette plasma: three waves → nearest color
pixel(x, y, t) =
palette_nearest(
sin(x * 0.08 + t),
sin(y * 0.06 + t * 1.4),
cos((x + y) * 0.05 - t)
)
Source Image
Upload a PNG using the ↑ src button in the output panel. The uploaded image is available to every pixel function via the src* builtins. Coordinates are clamped to image bounds; if no image is loaded, all builtins return black / 0.
| Function | Signature | Returns |
|---|---|---|
src(x, y) | (number, number) → color | pixel colour at (x, y) |
src_r(x, y) | (number, number) → number | red channel ∈ [0, 1] |
src_g(x, y) | (number, number) → number | green channel ∈ [0, 1] |
src_b(x, y) | (number, number) → number | blue channel ∈ [0, 1] |
-- Identity filter (upload a PNG first) pixel(x, y, t) = src(x, y) -- Greyscale via Rec.709 luminance lum(x, y) = luminance(src(x, y)) pixel(x, y, t) = palette_nearest(lum(x,y), lum(x,y), lum(x,y)) -- Animated tint pixel(x, y, t) = mix_color(src(x, y), RED, sin(t) * 0.4 + 0.4)
Dithering
| Function | Signature | Returns |
|---|---|---|
bayer(v, x, y, n) | v: [0,1]; x,y: coords; n: matrix size (4) | bool |
bayer(v, x, y, n) returns true when M[x%n][y%n] / n² < v — i.e., when the threshold at that position is below the input intensity v.
The result is a spatial pattern that approximates v as a density of ON pixels.
At n=4, the 4×4 Bayer matrix produces 17 distinguishable levels.
palette = PICO8 grad(x, y, t) = clamp(x / WIDTH * 0.6 + sin(y * 0.05 + t * 0.5) * 0.3 + 0.3, 0, 1) pixel(x, y, t) = bayer(grad(x, y, t), x, y, 4) ? WHITE : DARK_BLUE
Examples
Heart
The algebraic heart curve is the zero set of f(x,y) = (x²+y²−1)³ − x²y³. The interior (f < 0) is the filled heart. We normalize pixel coordinates to a unit-scale math frame with y pointing up.
palette = PICO8 cx = WIDTH / 2 cy = HEIGHT / 2 scale(t) = 28 + sin(t * 4) * 4 nx(x, t) = (x - cx) / scale(t) ny(y, t) = (cy - y) / scale(t) -- flip y: screen-down → math-up heart(x, y, t) = pow(nx(x,t)*nx(x,t) + ny(y,t)*ny(y,t) - 1, 3) - nx(x,t)*nx(x,t) * ny(y,t)*ny(y,t)*ny(y,t) < 0 pixel(x, y, t) = heart(x, y, t) ? RED : DARK_BLUE
Animated concentric rings
palette = PICO8 cx = WIDTH / 2 cy = HEIGHT / 2 pixel(x, y, t) = floor(dist(x, y, cx, cy) - t * 1.5) % 2 == 0 ? BLUE : DARK_BLUE
Fractal noise terrain
palette = PICO8 elev(x, y) = fbm(x * 0.015, y * 0.015, 6) pixel(x, y, t) = | elev(x,y) > 0.3 -> WHITE -- snow | elev(x,y) > 0.1 -> LIGHT_GREY -- rock | elev(x,y) > 0.0 -> DARK_GREEN -- grass | elev(x,y) > -0.3 -> BROWN -- sand | _ -> DARK_BLUE -- ocean
Conway's Game of Life
-- self[dx,dy] reads the previous frame (0=dead, 1=alive)
n(x, y) =
self[-1,-1] + self[0,-1] + self[1,-1] +
self[-1, 0] + self[1, 0] +
self[-1, 1] + self[0, 1] + self[1, 1]
pixel(x, y, t) =
| self[0,0] == 1 and n(x,y) in {2,3} -> 1
| self[0,0] == 0 and n(x,y) == 3 -> 1
| _ -> 0
Polar flower
palette = PICO8 cx = WIDTH / 2 cy = HEIGHT / 2 r(x, y) = dist(x, y, cx, cy) theta(x, y) = atan2(y - cy, x - cx) petals(x, y, t) = sin(theta(x,y) * 6 + t * 2) * 0.5 + 0.5 pixel(x, y, t) = | r(x,y) > 55 -> DARK_BLUE | petals(x,y,t) > 0.6 -> YELLOW | petals(x,y,t) > 0.3 -> ORANGE | _ -> RED
Plasma
palette = PICO8
pixel(x, y, t) =
palette_nearest(
sin(x * 0.08 + t),
sin(y * 0.06 + t * 1.4),
cos((x + y) * 0.05 - t * 0.8)
)
XOR fractal
pixel(x, y, t) = bxor(floor(x + t * 8), floor(y)) % 32 / 32.0
Errors
Compile-time
Shown with line:col in the error bar and as gutter annotations in the editor.
| Error | Cause |
|---|---|
| undefined variable/function | name not defined anywhere in the program |
| expected a number, got a color | color used in arithmetic expression |
| both branches must have the same type | ternary arms or match arms return different types |
| match is not exhaustive | no | _ -> ... wildcard and not all cases covered |
| 'x' refers to itself | cyclic definition detected |
Runtime
Pixel evaluation errors don't crash the canvas. The offending pixel renders as magenta (#FF00FF); all others continue normally.
| Error | Cause |
|---|---|
| division by zero | a / 0 |
| non-exhaustive match | no arm matched at runtime |
| domain error | sqrt(-1), log(0) → NaN → treated as 0 |
Magenta in output always means a runtime error. It never appears as a palette color in any built-in palette.
Quick Reference
-- program structure
palette = PICO8 | GAMEBOY | CGA
name = expr -- constant
name(a, b) = expr -- function
pixel(x, y, t) = expr -- entry point (required)
-- built-ins
x y t WIDTH HEIGHT PI TAU E
-- operators
+ - * / % ^ -- arithmetic (→ number)
== != < <= > >= -- comparison (→ bool)
and or not -- logic (→ bool)
cond ? a : b -- ternary
| guard -> expr -- match arm
v in {a, b, c} -- set test (→ bool)
self[dx, dy] -- neighbor (→ number)
-- most-used functions
sin cos atan2 -- trig
sqrt abs floor ceil fract -- scalar math
dist dist_sq -- geometry
clamp mix smoothstep -- interpolation
noise(x,y[,z]) fbm(x,y,n) -- noise
voronoi voronoi_id -- cellular
palette_nearest luminance -- color
bayer(v,x,y,n) -- dithering
wrap mirror -- tiling
band bor bxor bshl bshr -- bitwise