Skip to content

Repository files navigation

flix-invaders

Build and TestLatest releaseLicense: MIT

A Flix creative-coding pilot using Processing Core: an arcade game where the rules are a pure function, the tests never open a window, and the effect system marks exactly where the outside world begins.

It is not a Flix dialect, not a Processing Mode, and not affiliated with either project. It exists to answer one question: does Flix's effect system make creative coding clearer?

There is no Java in this repository. Flix subclasses Processing's PApplet directly.

Want to participate? Read the contribution guide, code of conduct, and security policy.

The attract screen: a computer player working through level 3 while the title sits over it

Attract mode — nobody is playing. The demo is an ordinary pure function of the world, and the same one the balance tests drive.

Play it without building it

curl -fsSLO https://github.com/wstein/flix-invaders/releases/latest/download/invaders
chmod +x invaders
./invaders

That script is the whole download. On first run it fetches the game and Processing Core into ~/.cache/flix-invaders, checks both against pinned SHA-1s, unpacks the arcade font and plays; after that it runs offline. Java 21 and nothing else. ./invaders --where prints the cache directory and --clean removes it — nothing is installed anywhere else.

Quickstart

You need Java 21+ and nothing else — the Flix compiler downloads itself on first use.

git clone https://github.com/wstein/flix-invaders
cd flix-invaders
./flixw check # type-check; the fast feedback loop
./flixw test# 441 tests -- no window, no audio device, no filesystem
./flixw run # play
bin/bench # measure the demo bot over ten seeds
bin/bench --wide # sixty seeds instead; the only sample that settles a close call
bin/bench --recalc # search for better bot numbers and save them

Controls:1 or 2 at the title screen picks one or two players; arrows move, space fires, enter moves on, F3 shows stats for nerds. With two players, player one plays their whole game before player two starts, after a PLAYER 2 / GET READY count — the arcade original hands over on each destroyed cannon, and this deliberately does not. Shelter behind the bunkers — they stop bombs but wear away. Shoot the saucer for points and a temporary shield — a dome over the cannon that flashes for its last couple of seconds, so you can see it going. Every 2500 points buys a life. Clearing the formation starts a harder level; there is no winning, only surviving longer.

Leave it alone at the title and it plays itself, and it is meant to look like a person doing it: the bot misjudges a shot and lives with the misjudgement for a beat, needs a real reason to turn the cannon round, and now and then stops watching. All of that is still pure -- it threads a Hand the way the game threads its Rng -- and it is the same code the balance test drives, good enough to reach about level seven.

The table keeps twelve places, and a qualifying score asks for three initials. It lives in ~/.config/flix-invaders/scores.txt (or $XDG_CONFIG_HOME). Delete it to start over; it is a text file, and a damaged one costs you the table, not the game.


For students: read it in this order

This codebase is meant to be read, not just run. Each step adds exactly one idea.

Steps 1 to 5 build the game: a picture, then state, then rules, then the one place Java appears. Steps 6 and 7 are the parts that are Flix rather than functional programming in general — logic programming embedded in the language, and effects tracked well enough that a benchmark can play thousands of games without a window.

flowchart LR
subgraph build["the game"]
direction LR
A["1 · Still<br>a fixed picture"] --> B["2 · Animation<br>state and time"]
B --> C["3 · Collide, Sprites<br>small pure functions"]
C --> D["4 · Game.step<br>rules plus Sound"]
D --> E["5 · Sketch.flix<br>where Java begins"]
end
subgraph flix["what makes it Flix"]
direction LR
F["6 · TestScreenGraph<br>Datalog, in the language"] --> G["7 · Tuning, Bench<br>configuration and measurement"]
end
E --> F
Loading
#ReadRunThe one new idea
1Sketches/Still.flixbin/sketch staticDrawing is a sequence of operations. A window is only one way to interpret them.
2Sketches/Animation.flixbin/sketch animationA world, and a pure step that advances it. No clock, no frame counter.
3Invaders/Collide.flix, Sprites.flix./flixw testOrdinary functions, tested directly. Pixel art is data written as text.
4Invaders/Game.flix./flixw runThe rules are a compact function of world and input; sound is the one concrete effect.
5Runtime/Sketch.flixWhere purity stops and Java starts. One file.
6TestScreenGraph.flix./flixw testDatalog is in the language. Three rules prove no screen can trap the player — and the facts are discovered by running the real code, not typed out.
7Tuning.flix, Bench.flixbin/benchJSON configuration makes a search an ordinary loop; a headless benchmark plays ten games — or sixty, with --wide — and reports the result.

Your first change, in under a minute: open Still.flix, change a colour or move the sun in its Canvas.ellipse call, then bin/sketch static again.

Your first real change: in Animation.flix set gravity() to 0.15f32 and watch the balls fall. You changed a pure function; nothing else moved.

Your first measured change: run bin/bench, raise dangerWidth in ~/.config/flix-invaders/tuning.json from 60 to 90, and run it again. You have just changed how the computer plays and can say by how much — which is step 7's whole point.


Architecture

Everything in the top box is pure — no IO, no window, no device. It can all be tested headlessly, and it is.

flowchart TB
subgraph pure["PURE - no IO, no window, no device"]
direction LR
cab["Invaders/<br>Session · Screens · Demo<br>the cabinet around the game"]
model["Invaders/<br>Game · View · Bunkers<br>Collide · Types · Sprites"]
demos["Sketches/<br>Still · Animation"]
end
subgraph seam["THE BOUNDARY - three effects"]
direction LR
canvas["Canvas<br>effect"]
input["Input<br>effect"]
sound["Sound<br>effect"]
end
subgraph java["TOUCHES THE OUTSIDE WORLD"]
direction LR
sketch["Runtime/Sketch.flix<br>window · frame loop · keys"]
audio["Runtime/Audio.flix<br>synthesis · clip pool"]
main["Main.flix<br>the high-score file"]
end
ext["Processing Core JAVA2D<br>javax.sound.sampled<br>~/.config"]
cab --> model
cab --> canvas
model --> canvas
model --> sound
demos --> canvas
input --> model
canvas --> sketch
sound --> audio
sketch --> ext
audio --> ext
main --> ext
Loading

Exactly two files in src/ mention Java: one for the window, one for the sound card. A third, Main.flix, can reach a filesystem — for one text file, on the way in and the way out. Everything else cannot open a device or a file even by accident, because the types forbid it.

The frame loop

Processing calls draw() on its own thread. The runtime turns that into a whole number of simulation steps, so behaviour never depends on frame rate.

sequenceDiagram
autonumber
participant P as Processing<br/>Animation Thread
participant S as Sketch.start
participant G as Game.step<br/>(\ Sound)
participant A as Audio
participant V as View.render<br/>(\ Canvas)
P->>S: draw()
S->>S: nanoTime into accumulator<br/>(clamped to 5 steps)
S->>S: freeze one Input.Snapshot
S->>S: Input.ticksDue decides N,<br/>Input.acrossTicks gives the edges<br/>to the first tick only
loop N ticks
S->>G: step(world, snapshot)
G->>A: Sound.play, handled as Clip.start()
G-->>S: next world
end
Note over A: Clip.start() returns at once,<br/>so the frame never stalls
S->>V: render(world)
V-->>P: fill · rect · text
Loading

Two properties fall out of this shape:

  • Frame-rate independence. A slow frame runs more steps, not bigger ones. Every step covers exactly 1/60 s.
  • The keyboard is read once per frame. Every step in that frame sees the same keys held, so holding a direction moves the cannon through all of them. Only the first step sees the edges: a press is a transition, and handing it to three catch-up steps would fire a one-off action three times. Together those make a recorded run replay exactly.

The rules, as a pipeline

Game.step is a deterministic function with a concrete \ Sound effect, built from small named stages, each readable and tested on its own.

flowchart TB
subgraph intent["intent"]
a1[movePlayer] --> a2[fire]
end
subgraph motion["motion"]
b1[moveBullets] --> b2[moveBombs] --> b3[marchInvaders] --> b4[moveMystery] --> b5[invaderFire]
end
subgraph contact["contact"]
c1[ageShield] --> c2[resolveBunkers] --> c3[resolveBullets] --> c4[resolveMystery] --> c5[resolveBombs]
end
subgraph books["bookkeeping"]
d1[awardBonusLife] --> d2[ageBlasts] --> d3[trackHiScore] --> d4[checkEnd]
end
intent --> motion --> contact --> books
Loading

Order carries meaning. Bunkers resolve before invaders, so a bunker genuinely stops a shot. The saucer resolves after the formation, so a bullet must pass everything below it first. The shield ages before anything can grant one, so a new shield lasts its full duration.

Phases

There is no winning. Clearing the formation starts a harder level; the only ending is losing.

stateDiagram-v2
[*] --> Playing
Playing --> Cleared: last invader destroyed
Cleared --> Playing: after a beat - next level,<br/>faster, more bombs, fresh bunkers
Playing --> Lost: lives exhausted or<br/>invaders reach the line
Lost --> Playing: Enter (hi-score survives)
Loading

One effect, three interpretations

This is the idea the whole project exists to demonstrate. View.render says what to draw and never learns where. Sound works the same way: runWithClips, runWithCollector, runWithNoOp.

flowchart LR
R["View.render(w)<br/>uses only the Canvas effect"]
R --> H1["runWithSurface"]
R --> H2["runWithCollector"]
R --> H3["runWithNoOp"]
H1 --> O1["a Processing window"]
H2 --> O2["List of DrawCmd<br/>headless tests"]
H3 --> O3["nothing<br/>benchmarks"]
Loading

Adding a fourth — a draw-call counter, an SVG exporter — means adding a function to Canvas.flix, not touching the runtime.

Game.step has a concrete \ Sound effect because of the JVM callback, not because Flix requires effects to be rigidly isolated. Sketch.start becomes Processing's fixed draw method and therefore cannot be generic over an effect variable; the handlers themselves remain effect-polymorphic, so they can run in a larger effect context. The runtime installs the concrete Sound handler inside draw, where the JVM boundary permits it.


Design notes

  • Sound is an effect, like drawing.Game.step says what should be heard; the runtime plays it onto a clip pool, the tests collect it into a list, and a machine with no sound card discards it — all three running the identical simulation.
  • Randomness is data, not an effect. An Rng threads through step like any other part of the world, so determinism is structural rather than depending on which handler someone installed. See ARCHITECTURE.md for why sound went the other way.
  • Pixel art, no image files. Sprites are rows of # and . in Sprites.flix, parsed once by Sprite.of into horizontal runs. A full frame with 55 invaders and four bunkers costs about 1.9 ms: 0.13 ms simulation and 1.74 ms drawing.
  • Colours are checked, not eyeballed.TestContrast.flix asserts WCAG 2.1 ratios for the whole palette — 4.5:1 for text, 3:1 for shapes.
  • Balance is a test.TestDifficulty.flix drives the real game with the same bot that plays the attract screen, asserting it clears level one on every seed, reaches about level four, and still eventually loses. It caught a regression a human had reported.
  • No @DefaultHandler on Canvas or Input, deliberately. A silent default would let a test that forgot to choose an interpretation compile and assert on a frame nobody drew.

Testing

441 tests, none of which open a window, an audio device, or the real filesystem — CI enforces all three with greps.

AreaTestsWhat it pins down
TestGame110every rule, hit boxes, levels, shield, bonus lives
TestSession56screens, taking turns, typed initials
TestAnimation27elastic collisions; conservation of momentum and energy
TestBunkers25damage, absorption, erosion, camping behind a drilled slit
TestSprites19run-length decomposition of the pixel art
TestDemo33the computer player: aim, dodge, when to fire, narrowing the block, and not shooting its own cover
TestScores16the table's format and ordering, with no handlers at all
TestCanvas14the effect and its interpretations
TestContrast10WCAG contrast of every palette colour
TestRng10determinism, range, distribution
TestReplay11identical input replays to an identical world and an identical soundtrack
TestCollide + TestInput27overlap convention, input edges, one frame across many ticks
TestScoresFile8saving and loading, on a filesystem that does not exist
TestStats15the telemetry overlay, and that showing it changes nothing
TestView12banner placement against the attract panel, and the countdown
TestScreenGraph4no screen traps the player — reachability, in Datalog, over injected facts
TestBench18the benchmark's own arithmetic — rates, worst cases, cut-short runs, counters that cannot go negative
TestTuning13the tuning file: round trip, overrides, clamping
TestDifficulty6the game is winnable, not trivial, and the demo reaches about level seven

Stats for nerds

F3 puts the runtime's own telemetry in the corner, in the style every game with one of these uses:

FPS 59.9 SCREEN Attract
SIM 0.13 MS TICK 900 LEVEL 1
DRAW 1.74 MS INVADERS 10 / 55
BUDGET 1.87 / 16.67 SHOTS 1 UP 1 DOWN
STEPS 1 CATCHUP 0 BUNKERS 262
FRAMES 1 DIGEST Playing|t=900|x=288.0|..

Two things about it are worth knowing. The timings bracket the work, not the frame: Processing sleeps to hold the target rate, so a stopwatch around a whole frame measures the rate limiter and reports ~16.7ms whatever the sketch costs. And DIGEST is the same fingerprint the replay tests compare, so two runs that should be identical can be checked against each other by eye.

The frame loop is the only thing that can measure any of this and the view is the only thing that can show it, so it travels between them as an ordinary value — nothing in between learns that a clock exists. Switching it on cannot change what the game does, and a test holds it to that.

Building a release

./flixw build-jar # -> artifact/, about 12 seconds

Two things to know about it.

Clean first.build-jar packages whatever is sitting in build/class, and Flix does not remove the classes of earlier builds — a working copy built a few dozen times accumulates over a million class files and several gigabytes, and every one of them lands in the jar. rm -rf build before building a release; ./flixw clean does not get all of it.

The jar carries no dependency. It holds this project's classes and its own font, and expects Processing Core beside it. The release build also strips the compiled test suite, which Flix packages along with everything else and which is half the artifact's size — 13 MB down to 6.5 MB — and then fails if a single Processing class made it in:

java -cp flix-invaders.jar:core-4.5.6.jar Main

flix build-fatjar would fold Processing in and must not be used: it is LGPL-2.1, and shading converts dynamic linking into static linking, which triggers the relinking obligation in section 6. Keeping it separate is also what makes it replaceable — see THIRD-PARTY.md.

Releases are cut by tagging. release.yaml checks, tests, builds the jar, runs it headless under Xvfb for 120 frames, stamps the launcher with the tag and the jar's SHA-1, and publishes both:

git tag v0.2.0 && git push --tags

Non-goals

Deliberately out of scope: image and audio assets, networking, a game engine, a browser playground, and a Processing Mode. Sprites are text in the source and sounds are arithmetic — the arcade font is the only binary the game loads. (The recording above is documentation; it is never read at runtime.)

Persistence was on this list. It came off for the high-score table, and stayed as small as it could: one text file, written by main alone, on the way out. Nothing else in the project can reach a filesystem, and Scores decides what the table contains with pure functions that need no handler to test.

Remix it

The game is one possible outcome, not the point. Roughly in order of effort: change the palette (the contrast tests keep you honest); retune the rules — march speed, fire rate, formation shape; give the invaders a different movement grammar in marchInvaders; change the win condition in checkEnd. Each is a pure function with tests around it.

Documentation

License

MIT. Links at runtime against Processing Core (LGPL-2.1) and bundles the Press Start 2P font (SIL OFL 1.1) — see THIRD-PARTY.md.

About

An arcade game written in Flix driving Processing Core: the rules are a pure function, the tests never open a window, and the effect system marks exactly where the outside world begins. No Java in the repository.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages