Cellua | ← Editor | Language Reference

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.

TypeRepresentationExamples
number64-bit float (f64)3.14, -1, x / WIDTH, sin(t)
colorRGBA u8×4RED, #ff004d, palette_nearest(r,g,b)
booltrue / falsex > 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

NameTypeValue
xnumberpixel column, 0 = left edge
ynumberpixel row, 0 = top edge
tnumberelapsed seconds since program start
WIDTHnumbercanvas width in pixels
HEIGHTnumbercanvas height in pixels
PInumberπ ≈ 3.14159
TAUnumber2π ≈ 6.28318
Enumbere ≈ 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

CategoryOperatorsOperand typesResult
Arithmetic+ - * / % ^number, numbernumber
Comparison== != < <= > >=number, numberbool
Logicand or notbool (bool)bool
Ternarycond ? a : bbool; T, TT
Match arm| guard -> exprbool; anyarm type
Set testv in {a, b, c}number; numbersbool

Precedence (high → low): unary - not^* / %+ - → comparisons → andor? :.

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

FunctionReturnsNotes
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
atan2(y, x) gives the polar angle θ ∈ (−π, π] of the vector (x, y). Use it to convert Cartesian to polar: 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

FunctionReturnsNotes
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 f64nearest 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

FunctionFormula
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

FunctionFormulaNotes
clamp(v, lo, hi)max(lo, min(hi, v))pins v to [lo, hi]
mix(a, b, t)a + (b−a)·tlinear 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 mixblend two colors; t∈[0,1]
smoothstep applies the cubic Hermite polynomial S(t) = 3t² − 2t³ to a normalised input. S(0)=0, S(1)=1, S'(0)=S'(1)=0 — zero derivative at both ends gives a smooth, C¹-continuous transition with no abrupt edges.

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

FunctionReturnsNotes
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
Gradient noise generates a continuous pseudorandom scalar field. Values at nearby coordinates are correlated; values at coordinates far apart (relative to unit scale) are essentially independent. The output has no obvious grid artefacts and derivatives are smooth.
Fractal Brownian Motion sums octaves layers of noise, each at double the frequency and half the amplitude:

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

FunctionReturnsNotes
voronoi(x, y, n)≥ 0distance to nearest of n seeds
voronoi_id(x, y, n)[0, n)index of nearest seed (integer-valued f64)
Voronoi diagram: n seed points are placed using a deterministic pseudorandom function of their index. Each pixel is assigned to its nearest seed. 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

FunctionFormulaUse for
wrap(x, w)((x % w) + w) % wmodular tiling, always ≥ 0
mirror(x, w)fold x in period wmirror-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

FunctionOperation
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

FunctionSignatureReturns
palette_nearest(r, g, b) → colorclosest palette entry by RGB distance
luminance(color) → numberperceptual luminance ∈ [0, 1]
mix_color(color, color, t) → colorlinear blend; t∈[0,1]
Luminance uses the Rec. 709 coefficients: L = 0.2126 R + 0.7152 G + 0.0722 B. This weights green most heavily (it contributes the most to perceived brightness) and blue least.
-- 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.

FunctionSignatureReturns
src(x, y)(number, number) → colorpixel colour at (x, y)
src_r(x, y)(number, number) → numberred channel ∈ [0, 1]
src_g(x, y)(number, number) → numbergreen channel ∈ [0, 1]
src_b(x, y)(number, number) → numberblue 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

FunctionSignatureReturns
bayer(v, x, y, n)v: [0,1]; x,y: coords; n: matrix size (4)bool
Bayer ordered dithering simulates continuous tones using a binary pixel grid. An n×n threshold matrix M is derived by recursively interleaving bits of x and y. 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.

ErrorCause
undefined variable/functionname not defined anywhere in the program
expected a number, got a colorcolor used in arithmetic expression
both branches must have the same typeternary arms or match arms return different types
match is not exhaustiveno | _ -> ... wildcard and not all cases covered
'x' refers to itselfcyclic definition detected

Runtime

Pixel evaluation errors don't crash the canvas. The offending pixel renders as magenta (#FF00FF); all others continue normally.

ErrorCause
division by zeroa / 0
non-exhaustive matchno arm matched at runtime
domain errorsqrt(-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