- Rust 82.7%
- Nix 17.3%
A background build got through source unpack, the vendored projects' configure scripts and into build_compiler_rt.sh before it had to be cut off -- far enough to confirm the phase structure holds and to surface one real gap: configure_cmake_project.sh calls `hostname` to stamp a rebuild-detection cookie, and the sandbox has no PATH entry for it. It is harmless when missing, but add it rather than leave the error in the log. The comments and README now say what the partial run did and did not establish, rather than claiming the whole thing was written blind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nZrh5rXDy72uxx6mLaqTU |
||
|---|---|---|
| nix | ||
| rust | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| SECURITY.md | ||
iMaze, packaged with Nix
A Nix flake that builds iMaze — Hans-Ulrich Kiel and Jörg Czeranski's multi-player X11 maze shooter, the Unix answer to MidiMaze II on the Atari ST — from its original C sources, plus a full rewrite of it in Rust.
You run through a labyrinth and shoot everything round that is not one of your own shots. Anything round is either another player, a computer-controlled ninja, or a deadly shot.
.
├── flake.nix packages, apps, dev shell, checks
├── nix/imaze.nix the game, built from the 2001 C sources
├── nix/imaze-rs.nix the Rust rewrite
├── nix/filc.nix the Fil-C toolchain, from source
├── nix/imaze-filc.nix the server and tools, compiled memory-safe with Fil-C
├── nix/nixos-module.nix services.imaze
├── nix/patches/ 7 patches against the C sources
├── rust/ the Rust crates: imaze-rs (core, server, CLI) + imaze-gui
└── SECURITY.md a memory-safety bug in the C client, and why Rust is clear
Quick start
# One terminal: a server on a freshly generated 15x15 labyrinth
nix run .#genlab -- -w 15 -h 15 -d 25 arena.lab
nix run .#imazesrv -- -l - arena.lab
# Another terminal: the game
nix run . -- -H localhost
# And some opposition
nix run .#ninja -- -H localhost
The eleven labyrinths shipped with the game are in
$(nix build .#imaze --no-link --print-out-paths)/share/imaze/labs if you would
rather not generate your own.
In the client, connect with Server → Connect (the -H flag only fills in
the address field). Cursor keys move, space/shift/alt shoot — you only ever
have one shot in flight — ^S pauses, ^Q resumes, Tab turns you around if
the server allows it. Window opens the rear view, map, compass and score
list. Click a window first so it takes keyboard input.
man imaze, man imazesrv, man ninja, man genlab, man xlabed and
man imazestat document the rest; share/doc/imaze/README in the package is
the authors' original text.
What the flake provides
| Output | |
|---|---|
packages.imaze (default) |
the game, Athena widgets |
packages.imaze-motif |
same, Motif control panel |
packages.imaze-rs |
the Rust rewrite: imaze, imazesrv, ninja, genlab, imazestat, imazelab |
packages.filc |
the Fil-C toolchain, built from source (Linux) |
packages.imaze-filc |
the server and tools, compiled memory-safe with Fil-C (Linux) |
apps.{imaze,imazesrv,ninja,imazestat,genlab,xlabed} |
one per C binary |
apps.{imaze-rs,imazesrv-rs,ninja-rs,genlab-rs,imazestat-rs,imazelab} |
the Rust ones |
overlays.default |
adds imaze, imaze-rs, filc, imaze-filc to a nixpkgs instance |
nixosModules.imaze (default) |
services.imaze, for hosting a server |
devShells.default |
both build environments, C and Rust |
checks |
see below |
imaze installs the six programs, the eleven labyrinths and eight .au sounds
under share/imaze/, man pages, and desktop entries for the client and the
labyrinth editor.
Package options
pkgs.imaze.override {
guiToolkit = "motif"; # or "athena" (default)
withAudio = false; # drop the OSS /dev/dsp backend
withJoystick = false; # drop <linux/joystick.h> support
defaultServer = "maze.example.org";# baked-in default host
}
defaultServer is worth setting if you run a permanent server: upstream's
compiled-in default is imaze.rz.tu-clausthal.de, which stopped answering a
very long time ago.
Sound goes to /dev/dsp — that is the only backend iMaze ever grew on Linux.
On a PulseAudio or PipeWire desktop, run the client as padsp imaze, or pass
-q to turn sound off.
The two packages both install bin/imaze, bin/imazesrv, bin/ninja, bin/genlab and
bin/imazestat — that is what "drop-in" means — so putting both into one
profile is a file collision. Pick one, or reach the Rust ones through
nix run .#imaze-rs and friends.
Hosting a server
{
imports = [ imaze.nixosModules.default ];
services.imaze = {
enable = true;
openFirewall = true;
bots = 2; # so nobody arrives to an empty labyrinth
generate = { width = 24; height = 24; };
};
}
That is the whole of it. The labyrinth is generated at build time from
services.imaze.generate — width, height, wall and door density, and a seed
that is fixed by default so that rebuilding your configuration does not
quietly replace the arena everyone knows — or you can point
services.imaze.labyrinth at a .lab file of your own, including one of the
eleven the game ships.
The rest of the options are the server's own: port and sessionPort,
cycleTime, and rules.{faceless,colorless,reflective,quickturn,invisible}.
package chooses which implementation to run; both provide imazesrv, and
the Rust one is the default because only it takes a fixed sessionPort.
The service runs under DynamicUser with the usual systemd sandbox — no
capabilities, no writable filesystem, AF_INET and AF_INET6 only — because
it reads one file at startup and then pushes datagrams about. Each bot is its
own unit, so one that wanders off is restarted without disturbing the others,
and all of them are bound to the server's unit.
Two ports, not one. A client meets the server over TCP and is then handed
to a UDP port for the game itself, so openFirewall opens both.
sessionPort exists because the server would otherwise fall back to any free
port if its usual one were taken, and a firewall rule written for 5323 would
then be pointing at nothing; naming the port makes the server insist on it and
fail loudly instead.
Clients behind NAT. The 1994 protocol has the server speak first and the client only ever answer, which no stateful firewall or NAT will allow — the server's opening datagram arrives before anything has gone out to create the mapping. The Rust client sends one empty packet as soon as it has swapped addresses, which opens the way and costs nothing; a server that has not finished registering it yet simply ignores it. The C client does not do this, so playing on a remote server from behind NAT needs the Rust one, or an inbound UDP hole.
The Rust rewrite
rust/ is a rewrite of the whole of iMaze — client, server and bot. It speaks
the 1994 protocol in both directions, so the pieces mix freely with the
originals: the Rust client plays on imazesrv, and the 1994 client plays on
the Rust server. It is checked that way too, rather than merely resembling the
original.
nix run .#genlab-rs -- -w 15 -h 15 -d 25 arena.lab
nix run .#imazesrv-rs -- -l - arena.lab # or .#imazesrv for the C one
nix run .#imaze-rs -- -H localhost # the game
nix run .#ninja-rs -- -H localhost # something to shoot at
nix run .#imazelab # the labyrinth editor
imaze_rs::rng |
the game's 4.3BSD random(), reimplemented exactly |
imaze_rs::lab |
the iMazeLab1 file format, read and write |
imaze_rs::generate |
baulab.c's generator and its playability test |
imaze_rs::protocol |
the TCP "prolog" handshake, both roles |
imaze_rs::game |
the UDP session as a client sees it |
imaze_rs::view |
the ray caster the view is drawn from |
imaze_rs::server |
the world: movement, shots, scores, respawns |
imaze |
the game, drawn with egui |
imazesrv |
the server |
ninja |
a computer-controlled player |
genlab |
drop-in replacement, byte-identical output |
imazestat |
drop-in replacement, same output from the same server |
imazelab |
an egui labyrinth editor |
The game
Arrows move, space (or shift, or alt) shoots — one shot in flight at a time.
Tab turns you round, ^S pauses, ^Q resumes. Click the view first so it
takes the keys; while a text field has focus the keys go there instead. The
side panel has the scores, a field-of-view slider and an overhead map.
The server sets the pace — one datagram every 60ms, each of which has to be answered with the keys that are down — so the session runs on its own thread and the interface reads the latest state from behind a mutex. A key held between datagrams still turns you smoothly because the view redraws at 30fps regardless.
Unlike the C client, -H host connects straight away rather than only filling
the address in.
How the view is drawn
rechne3d.c is 2000 lines of fixed-point arithmetic that walks the visible
walls and projects their corners. This casts one ray per screen column
instead — the same picture, arrived at the way a maze game usually does it now.
The one thing that is not the textbook version is whose wall a ray hits. In iMaze a wall belongs to the block it is drawn in rather than to the boundary between two blocks, so the two faces of one wall can differ; that is what a one-way door is. A ray leaving a block therefore asks about the face of the block it is leaving, which is exactly the face a player standing there sees. There is a test for it: from one side the wall is there, from the other the ray carries straight through.
Players are drawn as balls with a face whose eyes swing round as they turn, so you can tell whether they have seen you, and are clipped column by column against the wall depths — a player half behind a corner is drawn half.
The caster is not the expensive part: a full 1600-column frame of a 25×25
labyrinth casts in about 0.018ms, and the wall mesh is a thousand-odd quads
that the GPU does not notice. What made motion look slow was the update rate,
not the frame cost — the world only advances when a datagram arrives, about
sixteen times a second, and the same frame was being redrawn in between. The
client now keeps the last two server states and draws a point on the way from
one to the next, chosen by the wall clock, so everything moves smoothly at the
display's rate while the server still ticks at its own underneath. It trails
the truth by up to one cycle and never guesses past the latest state, so a
stall freezes rather than flinging players off. This is purely how the client
draws; the server sees nothing different, and the interpolation is covered by
tests in the imaze binary.
The labyrinth tools
Byte-identical is the interesting part. global.c carries its own copy of the
BSD additive-feedback generator, so reimplementing it exactly means the whole
generator is reproducible: genlab -r 42 -w 8 -h 8 writes the same 327 bytes
whichever implementation you run. The C version keeps its state in
unsigned long, which is 64 bits wide here, but only bits 1..31 ever reach the
output and carries only travel upwards, so u32 arithmetic is exact.
Two behaviours were kept even though they look like bugs, because matching the original is the point:
- Option arguments go through
atoisemantics, so-w 16abcmeans 16. - iMaze's stock defaults (16×16 at 38% walls) fail the playability test for a
thousand seeds in a row and give up. The C
genlabdoes exactly the same; it is only usable with-dturned down or the field made smaller. There is a test pinning this.
imazelab reads and writes the same files as the game's own xlabed, rolls
new labyrinths with the ported generator, tells you live whether the result is
playable and whether the server would complain about any wall, and can ask a
running server who is on it. Because walls are stored per block, the two sides
of one wall are separate things: the editor draws them as two parallel strokes
and lets you edit each, which is how a one-way door is made.
The server
imazesrv holds the labyrinth, hands it out, moves everyone, decides who shot
whom, keeps the scores and puts the dead back somewhere quiet. It takes the
same options as the C one — -p, -l, -c, and the rule flags -F, -C,
-R, -Q, -I — plus -r to fix the random seed. It does not implement
-A (autoanswer).
Where the C server forks a process per session and another per client being initialised, and talks to them over socket pairs, this runs one thread per connection for the prolog — a client that stops halfway must not hold up a match — and one thread for the game, which owns the world and the single UDP socket everybody plays over.
The constants are the game's own: a block is 256 fine units, a player is a circle of radius 48, a step is 25 and a shot travels twice that, being shot costs thirty cycles, and you get one point and one shot in flight.
What is not a port is the collision arithmetic. bewegung.c slides a player
along a wall by fitting a straight or circular correction path to the
obstacle, in about 900 lines of fixed point. Here a step is resolved one axis
at a time and clamped against whatever the circle would cross, which slides
along walls and around corners just as readably, in about thirty lines. The
server is the authority on where everyone is, so a client cannot tell the
difference — and the 1994 client, pointed at it, does not.
Averaged over five seeds a bot covers about the same ground on either server (3033 units against 3443), though the spread per seed is wide either way: how far you get depends mostly on where you spawned.
Checks
nix flake check builds both toolkit variants, runs the Rust test suite
(93 tests, the egui passes included), and runs eight functional tests, one of
them a NixOS machine:
server-and-botgenerates a labyrinth, startsimazesrvon it, lets aninjabot join over loopback and confirmsimazestatreports the bot and its kill message.client-opens-a-windowstarts the real X11 client againstXvfband confirms it maps a window and stays up, and that the compiled-in sound directory points into the package's own store path.rust-plays-on-the-c-server— the Rust bot joins a realimazesrvnext to a Cninja, plays for five seconds and leaves again.imazestathas to list it while it plays, the server log has to show it as a player rather than a query, and it has to exit cleanly, which takes the prolog, the handover to UDP, five seconds of datagrams answered, and the server acknowledging the request to leave. It is the graphical client's network path exactly, with nothing drawn.c-clients-play-on-the-rust-server— the same in the other direction, and the one that pins the server: the 1994ninjaand the 1994imazestatare pointed at the Rustimazesrv, and the Rust bot alongside them has to play a hundred cycles and cover some ground. The old bot is not going to be talked round, so if it plays, the server's half of the protocol is right.nixos-moduleboots two machines. The server comes up under systemd with a generated labyrinth and two bots; from the other machine, through the firewall, the 1994imazestatreads the status listing and a bot plays a game across the network. The player's firewall is left on, so this is also what checks that a client can open its own way in. Then the service is restarted, to catch a server that leaves its ports behind.rust-genlab-matches-cruns bothgenlabs over ten parameter sets and requires the same exit status and, where they succeed, identical bytes.rust-imazestat-matches-cputs two bots on a server and diffs what the twoimazestats report.rust-labyrinths-load-in-the-c-serverhosts a Rust-generated labyrinth on the C server, plays a bot on it and requires the server to load it without warnings.
Notes on the source
- Version 1.4 (December 2001) is the authors' final release and the one
packaged here. The
imaze.READMEon ftp.gwdg.de describes 1.2, whose client can only be built against Sun's XView toolkit — XView has no packaging in nixpkgs and has been dead for decades, so 1.2 would give you the server and the bots but no game. 1.4 replaced XView with Athena and Motif front ends over the same Xlib renderer, and adds the labyrinth editor, the server query tool and configurable game rules. - The 1.4 tarball is fetched from FreeBSD's ports distfile cache, which is the last mirror still carrying it; the source hash pins the contents regardless of where it comes from.
- Upstream's hand-written
configurehardcodes paths like/usr/X11R6in its per-OS branches, so the package asks for its neutralOS=unknownbranch and states the toolkit, audio and joystick choices explicitly.
The patches
Two are needed to compile at all against a 2026 toolchain:
fehler_text()usedsys_errlist[]/sys_nerr, which glibc no longer lets new binaries link against —strerror()instead.grafik.husedu_charwithout including<sys/types.h>; it only ever worked because of include order elsewhere.
The other five fix things a current compiler is right to complain about, and together they make the build warning-free:
XKeycodeToKeysym→XkbKeycodeToKeysym; the old call has been deprecated since 2007 and ignores the XKB group.- A 40-byte buffer that a colour-allocation error message can overflow.
- A static
atan(int, int)returning an angle in the game's own units, which collides with the libm builtin gcc is free to act on. - A
ptrdiff_tprinted with%d. - The sound loader ignored
read()'s result, so a short read played uninitialised memory; its error paths also leaked the buffer and the file descriptor.
Everything else builds unmodified, with -std=gnu89 -fcommon for pre-C99 empty
parameter lists and tentative definitions.
These patches are compile fixes, not the memory-safety bug the C client has in its packet handling — see SECURITY.md. That one is left in the C (the sources are meant to build as they were) and simply does not exist in the Rust rewrite, which the audit there sets out.
Security
The 1994 C client has an out-of-bounds array read driven by a single colour byte from the server, described in SECURITY.md. The Rust rewrite is not affected — it masks and moduloes colours rather than indexing a fixed array by a raw network byte — and a regression test pins that. The rest of the Rust decoder was audited for the same class of bug and length-checks every variable-length field.
A memory-safe C build (Fil-C)
There is a third way to answer that bug class without rewriting anything: compile the original C with Fil-C, a memory-safe fork of LLVM/clang whose codegen bounds-checks every access. An out-of-bounds read then traps at the access instead of handing Xlib a wild pointer.
packages.filc builds the Fil-C toolchain from source — the LLVM fork,
its runtime, and its musl libc, driven by upstream's own build_base.sh.
packages.imaze-filc uses it to build the server and tools:
nix build .#imaze-filc # imazesrv, ninja, imazestat, genlab, memory-safe
Only the non-graphical programs are built this way. Fil-C code links solely against Fil-C-compiled libraries, and no Fil-C build of the Xaw/Xt/Xmu stack exists, so the graphical client is out — but the server, which is what parses packets from untrusted clients, is exactly the thing worth hardening, and it is X11-free.
Two honest caveats. Building filc is an LLVM-fork compile — the better part
of an hour, a lot of RAM and disk — so it is kept out of nix flake check;
you opt in with the build command above. And it was not built to completion
where it was written: a partial run got through source unpack, the vendored
projects' configure scripts and into the compiler-rt step — enough to confirm
the phases line up and to catch a missing build tool, now fixed — but the LLVM
compile past that point runs for hours and could not finish here, so it stays
unverified end to end. The source hash is pinned and verified and the flake
evaluates; any remaining rough edges on a first full build are the sort the
partial run was already shaking out.
Licence
iMaze is distributed under a BSD 3-clause licence with an additional clause
restricting use of the name "iMaze" — see Copyright in the source tarball or
share/doc/imaze/Copyright in the built package. The packaging and the Rust
code in this repository are offered under the same terms.