How it works

From homework to game

The project started as the second half of a Model-View-Controller assignment in Java: a model of shapes and snapshots, a command-file reader, and two views. The graphical view is a Swing window with Prev, Select and Next buttons. The web view writes one HTML file with an SVG per snapshot. Both still live in the repository, unchanged, and the albums they were written for are still on this site.

Lightson is the same model, played. The model is rebuilt with current Java: records for positions, colours and shapes, a sealed Shape interface with exactly two kinds, and an Album that keeps drawing order and freezes immutable snapshots. The game adds a package of its own on top. Nothing in the model, the parser, the renderer or the game imports Spring, and a test checks that.

The puzzle is linear algebra

Flipping a switch twice does nothing, and the order of flips never matters. So a whole play-through boils down to a set of switches, and the windows that end up changed are the XOR of what each of those switches reaches. Asking which sets light a tower is solving A x = b over GF(2), the field with two elements, where addition is XOR.

A tower has at most 64 windows, so a row of the matrix fits in one long and adding two rows is a single instruction. This is the heart of the solver, Gauss-Jordan elimination on machine words:

for (int col = 0; col < n && rank < n; col++) {
    long bit = 1L << col;
    int found = -1;
    for (int r = rank; r < n; r++) {
        if ((row[r] & bit) != 0) {
            found = r;
            break;
        }
    }
    if (found < 0) {
        continue; // a free switch: it will span the kernel
    }
    swap(row, rhs, found, rank);
    for (int r = 0; r < n; r++) {
        if (r != rank && (row[r] & bit) != 0) {
            row[r] ^= row[rank]; // adding two rows is one XOR
            rhs[r] ^= rhs[rank];
        }
    }
    pivot[rank++] = col;
}

What comes out is one solution plus a basis of the kernel: the "quiet patterns", sets of switches that change nothing when flipped together. Every solution is the first one plus some combination of quiet patterns. The Walk-up has no quiet pattern and so exactly one answer. The Grand has four, which makes sixteen answers, and the best of them use four switches. A five-by-five tower has two, and its best answer takes fifteen.

A position is a URL

Because a position is just a set of switches, it fits in an address. In /tower/walk-up/155 the last part is that set in hexadecimal, one bit per window. Every window on the page is a link to the address with its own bit flipped. Playing is following links:

Only two things change what the server remembers through a form post rather than a link: asking for a hint, because hints are counted, and restarting a tower. Results are kept in SQLite, one row per player and tower, under a random id from the site's only cookie. The cookie is issued when you first open a tower, and nothing is written until a request comes back carrying it, so crawlers and link previews leave no trace.

Nobody signs in here, so that id proves nothing, and the game does not pretend otherwise. A lit tower is only written down when your browser says the request came from one of the site's own pages, which is what clicking the last window looks like. Opening a link to somebody's finished tower shows it and changes nothing. One network address can only start so many new records an hour, which keeps a script from swelling the day's count. The stars are for fun; nothing here is worth cheating for.

Every picture is still an album

The night sky, the towers, the people behind the windows: all of it is rectangles and ovals with a flat colour, built as a list of the original model's shapes. A window keeps the same shapes whether it is lit or dark; only their colours change. That is deliberate. It means one move in the game is a handful of color commands in the homework's own language. This is the first switch of The Walk-up, top left, lighting three windows:

# flip the switch on floor 3, window 1
color w-0-0-glow 127 139 99
color w-0-0 238 245 255
color w-0-0-glass 255 207 92
color w-0-0-shine 255 234 160
color w-0-0-bar-v 238 245 255
color w-0-0-bar-h 238 245 255
color w-0-1-glow 127 139 99
color w-0-1-glass 255 207 92
color w-0-1-shine 255 234 160
color w-1-0-glow 127 139 99
color w-1-0-glass 255 207 92
color w-1-0-shine 255 234 160
snapshot 3 of 9 windows lit

CommandWriter is the parser run backwards, and a round trip through both gives back the same shapes. Under any tower, "Keep these moves as a photo album" writes your game out as a command file, one snapshot per switch, and opens it in the original viewer.

No JavaScript

The site's Content-Security-Policy header forbids scripts and inline styles outright, so everything that moves is plain HTML, SVG and one stylesheet:

What happens on a request

  1. Caddy terminates HTTPS at lightson.7107labs.com and proxies to this process on a localhost port.
  2. Spring Boot (embedded Tomcat on virtual threads) routes the URL to a controller method.
  3. TowerCatalog turns the id into a tower: one of the campaign's twenty-five, the tower of a given day, or a random one grown from the number in its name.
  4. Puzzle reads the position from the address, rejects anything that is not a real position of that tower, and works out which windows are lit.
  5. TowerView builds the scene out of model shapes and SvgRenderer draws it, switching over the sealed shape kinds.
  6. A Thymeleaf template wraps it in the window you see: an info bar, the canvas and a row of buttons, laid out like the Swing view the project began with.

The daily tower

Each day's tower is grown from the date with java.util.Random, whose algorithm is part of the Java specification, so the same date gives the same tower on any machine and after any restart. The generator starts from the solved picture and flips a few switches, which makes the result solvable by construction, then keeps the first candidate whose par is between three and six. It changes at midnight UTC, and earlier days stay playable.

The command language

One command per line. Coordinates start at the top-left corner, as in SVG and Swing. Lines starting with # are comments. The command word is case-insensitive. You can write an album of your own with it.

CommandArgumentsMeaning
shapeid rectangle|oval x y w h r g b Create a shape on top of the others. For ovals, x y is the centre and w h are the radii. Colours are 0 to 255.
moveid x yMove to an absolute position.
colorid r g bChange the colour.
resizeid w hNew width and height, or radii.
removeidTake the shape off the canvas.
snapshot[description]Freeze the canvas as the next snapshot.

Limits

Windows in a tower64, one bit each
Results kept400 days after you last played that tower
Pasted commands64 KB
Shapes on the canvas5,000
Snapshots per album200
Canvas side100 to 2,000, default 1,000
Pasted albums kept90 days after they were last opened, at most 5,000

Running it

Java 21 from Ubuntu's packages, one jar built with Maven, a systemd unit that restarts it if it dies, and Caddy in front for TLS and headers. The service listens only on localhost. Results and pasted albums go to one SQLite file through Spring's JdbcClient, with write-ahead logging, so they outlive restarts and deploys. /healthz reports the service's health, database included. The same code builds and tests on every push.