This was vibe coded, entirely and unapologetically. Nearly all of it β the Rust, the TypeScript, the tests, the app icon, this README β was written by Claude Code running Opus 5, with a human steering rather than typing. Read it with that in mind.
A cross-platform desktop client for playing NetHack on the public servers
(nethack.alt.org, Hardfought) with graphical tiles. It connects over SSH, reads
the vt_tiledata escape codes the servers already emit, and paints the vanilla
16Γ16 tileset over the map. Games run on the server, so scores, dumplogs and
ttyrecs are unaffected.
It will also run a NetHack installed on this machine, in a pseudo-terminal β see Playing locally.
Tauri 2 (OS webview, no bundled browser) + React/TypeScript frontend, Rust backend.
On macOS, with Homebrew:
brew install --cask statico/tap/nethack-tiles-clientThat taps statico/homebrew-tap the
first time and puts the app in /Applications. Later:
brew upgrade --cask nethack-tiles-client # get the newest release
brew uninstall --cask nethack-tiles-client # remove the appAn uninstall keeps your profiles. Add --zap to delete them as well. Saved
passwords are in the login keychain, so --zap does not remove those. A
state-log folder is where you put it, and nothing removes it for you.
Otherwise take the .dmg, .msi, .AppImage or .deb from the
releases page.
The macOS build is signed with a Developer ID and notarised, so it opens like
any other app. The Windows .msi is unsigned, so SmartScreen wants "More info
βΈ Run anyway".
To build it yourself, see Running.
-
Rust (stable) and Node 18+
-
A game account on the server you want to play on
-
Two lines in your
.nethackrcon the server (edit it through the dgamelaunch menu or the server's web editor):OPTIONS=vt_tiledata OPTIONS=windowtype:ttyBoth are required.
vt_tiledatais implemented in the tty window port only βprint_vt_codelives inwin/tty/wintty.cand nothing inwin/curses/references it β sowindowtype:cursessends no tile data no matter what else is set. The app detects the absence and says so.Note NAO uses a separate rc file per NetHack version (
.nethackrcfor 3.6.x,.nh500rcfor 5.0), so make sure you are editing the one for the version you actually play.
| Connect | Notes | |
|---|---|---|
| nethack.alt.org | ssh nethack@nethack.alt.org |
Also offers telnet on 23 or 14321; this client does not use it. |
| Hardfought | ssh nethack@us.hardfought.org |
Also eu. (London) and au. (Sydney). SSH only. |
Hardfought's SSH is on the regional hosts. The bare hardfought.org is the
website, served through Cloudflare, which does not proxy port 22 β it resolves
but can never accept an SSH connection, so a profile pointed at it fails. The
default profile uses us.hardfought.org; change the host to eu. or au. if
one of those is closer. Register once on the US host and the account syncs to
the other two within a couple of minutes.
This client speaks SSH only β see Not in v1.
A saved profile pointing at the bare domain is repaired to us.hardfought.org
when the profile file is loaded. Only that exact value is touched, never a host
you typed yourself.
A profile can point at a NetHack on this machine instead of a server. It runs
in a pseudo-terminal, because NetHack's tty interface needs a real one: it asks
for the window size with TIOCGWINSZ, puts the line discipline in raw mode,
and will not start without a controlling terminal. Everything above the
transport β the demultiplexer, the overlay, the display controls β is the same
either way (src-tauri/src/session.rs).
On a first run the app looks for one and offers it as a third profile, after
the two public servers. The search covers PATH plus the usual install
locations, because a GUI app on macOS is not started from a login shell and
typically inherits only /usr/bin:/bin:/usr/sbin:/sbin β Homebrew's nethack
would never be on it. The binary is asked its version with --version rather
than guessed at, since tile indices are positional and the wrong sheet draws
the wrong picture for nearly every glyph. Leaving a profile's command empty
means "find one at connect time", so it does not go stale when NetHack moves.
Tiles usually will not work locally. TTY_TILES_ESCCODES is a
compile-time option and most packaged builds leave it out β Homebrew's does,
which you can check with strings $(which nethack) | grep '%d;%d' turning up
nothing. Such a build plays perfectly well here, in ASCII; it simply never
sends a tile code, and the app says so. Tiles locally need NetHack built from
source with that option.
Disconnecting a local game sends SIGHUP, which is what NetHack handles by
saving β the same thing that happens when an SSH connection drops. Killing it
outright would lose the character and strand a lock file.
Local play is Unix-only for now; Windows needs a ConPTY implementation.
pnpm install
pnpm run app # run with hot reload
pnpm run app:build # packaged app, in src-tauri/target/release/bundlepnpm run app is tauri dev: it starts Vite and the Rust backend together and
opens the window. pnpm run dev starts only the frontend, in a browser, where
none of the Tauri commands exist -- useful for styling, useless for playing.
The package manager is pnpm, and pnpm-workspace.yaml sets
minimumReleaseAge: 1440. A version published less than a day ago will not be
installed. Most of the npm attacks that have mattered were packages that looked
fine for a few hours and were pulled once somebody read them; waiting a day
costs nothing here and skips that window entirely. It has one visible effect:
adding a dependency released this morning fails until tomorrow. Wait, pick the
previous version, or list the package under minimumReleaseAgeExclude if it
genuinely cannot wait.
Install scripts are blocked unless a package is named in allowBuilds, which
today is only esbuild.
pnpm run test:all # both suites
pnpm test # frontend (vitest)
pnpm run test:backend # backend (cargo)
pnpm run check # tsc --noEmit, then cargo check
pnpm run lint # clippy, warnings treated as errorsclippy is Rust's linter: cargo check asks whether the code compiles,
clippy asks whether it should have been written that way -- redundant
closures, a map_err that wanted inspect_err, that sort of thing. It ships
with rustup (rustup component add clippy if it is missing).
The parts with real logic are pure and unit tested: the escape-code demuxer, glyph-flag decoding, tileset geometry, the profile store, the dgamelaunch login state machine, the tile grid, the stream player and the overlay painter. Where a test needed to know what a server really sends, the fixture is a verbatim capture rather than an invention.
The SSH transport only meets the login machine over a network, so that pairing has its own smoke test, ignored by default:
NHTILES_TEST_USER=someaccount NHTILES_TEST_PASS=secret pnpm run test:live-loginLocal play has the same arrangement -- pnpm run test:local-game starts
whatever NetHack is installed on this machine and checks that it draws.
pnpm run ship # 0.1.2 -> 0.1.3
pnpm run ship -- minor # 0.1.2 -> 0.2.0
pnpm run ship -- 1.0.0 # exactly that
pnpm run ship -- --dry-run # say what would happen, do nothingThat is the whole release. In order it bumps the version in the four files that
have to agree (package.json, tauri.conf.json, Cargo.toml, Cargo.lock),
commits, tags, pushes, waits for release.yml to
attach the Windows .msi and the Linux .deb/.AppImage, builds and
notarises macOS locally, writes the release notes from the commit subjects
since the last tag, publishes, and waits for the Homebrew tap to catch up.
It runs its checks before the bump rather than after the compile: macOS only,
clean tree, on main, gh logged in, both Rust targets installed, and the
signing certificate and notary password present. Every one of those has cost a
release.
If a later step fails, the tag already exists and rerunning from the bump would be wrong. Pick up where it stopped:
pnpm run ship -- --finish # notes, publish, tap
pnpm run release:macos -- --skip-build # just the upload, without rebuildingmacOS is not built in CI. Signing it requires a Developer ID key, and putting that key in a GitHub secret means handing a copy of it to every workflow run and every action they call. Instead it is built on a Mac that already has the key in its keychain:
pnpm run release:macosThat builds a universal .dmg, signs it, sends it to Apple to be notarised,
staples the ticket, checks the result with spctl the way Gatekeeper will, and
attaches it to the draft. It refuses to upload a build Apple rejected, because
signing and notarisation fail separately and an unnotarised .dmg looks
perfectly fine on the machine that made it.
It needs two things on that Mac, neither of them in the repo or in a shell profile:
-
A Developer ID Application certificate in the login keychain β made at developer.apple.com, installed by double-clicking the download. Not "Apple Development", which signs for local debugging and cannot be notarised.
-
An app-specific password from appleid.apple.com (Sign-In and Security βΈ App-Specific Passwords), stored in the keychain beside the Apple ID it belongs to:
security add-generic-password -s nethack-tiles-notary -a you@example.com -w
-wwith no value prompts, so the password stays out of shell history.
The team ID is in scripts/release-macos.mjs, which is not a secret: it is
already embedded in the signature of every build.
Publishing the draft on GitHub starts tap.yml, which checksums the .dmg and
rewrites Casks/nethack-tiles-client.rb in
statico/homebrew-tap.
Upload the macOS build before publishing. Publishing is the only thing that
starts the tap job, so a .dmg that arrives afterwards updates nothing β the
tap keeps offering the previous version. pnpm run release:macos refuses to
upload to an already-published release for this reason. If it happens anyway,
recover with:
pnpm run release:macos -- --skip-build --force
gh workflow run tap.yml -f tag=v0.1.2Two things have to be set up on the repo first:
TAP_GITHUB_TOKENβ a fine-grained PAT withcontents: writeonstatico/homebrew-tap. The built-inGITHUB_TOKENcannot reach another repository, so without this the tap step is the one that fails.- The first cask β
tap.ymlwritesCasks/nethack-tiles-client.rb, but the tap's README lists what it carries and is not touched by the workflow.
Servers compile NetHack with TTY_TILES_ESCCODES. With vt_tiledata on, the
tty port interleaves private escape codes into the stream
(win/tty/wintty.c):
| Code | Meaning |
|---|---|
ESC [ 1 ; 0 ; n [ ; m ] z |
Start glyph β n is glyph2tile[glyph], m is the MG_* flag mask |
ESC [ 1 ; 1 z |
End glyph |
ESC [ 1 ; 2 [ ; w ] z |
Select NetHack window w |
ESC [ 1 ; 3 z |
End of frame; the game is waiting for input |
ESC [ 1 ; 4 ; n z |
Sound cue (NetHack 5.0; parsed and ignored) |
Three details drove the design, and all three differ from a naive reading of the spec:
Tile placement needs a terminal. tty_print_glyph moves the cursor before
emitting the start-glyph code, so the target cell is wherever the cursor sits
once all preceding bytes are processed. Rather than reimplement a terminal
emulator in the backend to track that, the backend emits an ordered stream of
text and events, and the frontend asks xterm.js for the cursor inside a
write() callback β the exact point at which the terminal has caught up. See
src/lib/streamPlayer.ts.
The window code is a window id, not a window type. print_vt_code2(2, window)
passes a slot index into tty's wins[], not NHW_MAP. Tile placement therefore
keys off GlyphStart itself, which NetHack only ever emits for the map.
The flag bits moved in 5.0. NetHack 5.0 inserted MG_HERO at bit 0, shifting
everything above it: 0x08 is MG_PET on 3.6 but MG_DETECT on 5.0. Flags are
decoded per profile version in src-tauri/src/glyph.rs, which is the single
source of truth β the backend sends the frontend decoded booleans, never raw
bits.
A tile has to go when something writes over its cell β and comparing
characters cannot tell you that. An unlit map cell is drawn as a space, and
so is the gap between two words of a menu drawn on top of it, so a tile
anchored to its character survives being covered and gets painted over the
menu. The backend therefore splits the stream into printing and non-printing
runs (prints on StreamItem::Text), which is enough for the frontend to know
exactly which cells each write landed on: a printing run of n characters
occupies the n cells ending at the cursor once the terminal has processed it.
Any of those cells that is not a glyph's own character is retired
(src/lib/streamPlayer.ts, src/lib/tileGrid.ts). The recorded character is
kept as a backstop for anything that moves content around behind our back, such
as a scroll or a resize.
Two related details matter for the same reason:
- A glyph is anchored as soon as its character is on screen, not at the end
of the frame. NetHack writes exactly one character between
AVTC_GLYPH_STARTandAVTC_GLYPH_END, so by the next glyph it is there. Reading it later records whatever was drawn over the cell, which anchors the tile to the very thing that should have retired it. - The overlay reconciles at the end of every batch, not only on a frame
sync.
AVTC_INLINE_SYNCcomes fromtty_nhgetch, so it stops the moment NetHack exits β and dgamelaunch's own menus contain no tile codes at all. Waiting for one meant the last frame of the game stayed painted over the launcher.
The overlay steps aside entirely while a menu is up. Retiring covered cells
is not enough on its own, because a tty menu clears only the lines it writes,
and only when it is inset from the left edge β see process_menu_window and
erase_menu_or_text in win/tty/wintty.c. Everywhere else the map is still
genuinely on screen behind the menu. In ASCII that is a harmless leftover; a
tile is opaque where the character underneath was not, so an unlit floor
becomes a solid black block in the middle of the menu. AVTC_SELECT_WINDOW
says which window NetHack is drawing into, and windows are numbered in creation
order, so a window id above the map's is a menu or text window. The map's own
id is learned by watching which window glyphs arrive in, rather than hardcoding
a slot β which also keeps the message and status windows, created before the
map and sharing the screen with it, from blanking the tiles on every message.
The terminal's cursor is drawn back on top of a tile that covers it. The
overlay canvas sits over the terminal, so xterm's cursor block is behind the
tile and invisible β and during travel or a ; look, that cursor is the thing
the player is aiming. An outline is used rather than a filled block, since the
tile underneath is what is being aimed at.
Two sheets ship with the app, both vanilla 16Γ16 at 40 columns, built from
win/share/{monsters,objects,other}.txt at the matching release tag:
| Tileset | NetHack | Tiles |
|---|---|---|
vanilla-3.6.7-16 |
3.6.7 | 1082 |
vanilla-5.0.0-16 |
5.0.0 | 1515 |
They are embedded in the binary so dev and packaged builds resolve them identically.
Tile ordering is version-specific, and the two lines are nowhere near
compatible β 5.0 has 433 more tiles and renumbers almost everything. Picking
the wrong one does not fail loudly; it draws the wrong picture for nearly every
glyph. The profile's NetHack version selects a matching sheet automatically,
and the editor warns if you override it into a mismatch. An index the chosen
sheet does not cover is drawn as a ? placeholder rather than silently
skipped.
To build a sheet for another version or variant, download the three files from the matching NetHack tag and run:
pnpm run tiles --id vanilla-3.6.7-16 --name "Vanilla 16x16 (NetHack 3.6.7)" \
--version v36 --columns 40 --out-dir src-tauri/tiles \
monsters.txt objects.txt other.txtThe input order matters: tilemap.c walks monsters, then objects, then other,
and tile indices are positional.
The tile art is from NetHack and is covered by the NetHack General Public License.
A profile can write a folder of plain-text snapshots while you play, so you can point an LLM at the current game. Enable it on that server's Edit form, pick a folder, and connect. The game looks the same; the folder gets:
| File | Contents |
|---|---|
README.md |
How to read the folder |
screen.txt |
Current terminal, ASCII |
level.txt |
Current level layout |
messages.txt |
Last 1,000 top-line messages |
inventory.txt |
Last inventory menu you opened |
dungeon.txt |
Last ^o overview |
containers.txt |
Contents of each bag or box you looked inside |
Inventory, the dungeon overview, and container contents update only when you
open them (i, ^o, and looking inside a container). A multi-page listing is
joined as you flip through it. Those seven files are replaced for a new
session; anything else in the folder is left alone.
Two environment variables turn on diagnostics for a session, no rebuild needed:
NHTILES_LOG=/tmp/tiles.log NHTILES_RAW=/tmp/tiles.raw pnpm run tauri devNHTILES_LOG records every glyph next to the character NetHack drew for it
(tile=93 flags=0x0000 ch="@") plus a summary of the index range and anything
outside the sheet. That pairing is what identifies an ordering mismatch: if the
hero is ch="@" at tile 93 and the sheet's 93 is a rock mole, the sheet is
built for the wrong NetHack version. NHTILES_RAW dumps the raw server bytes
for offline replay.
Tiles are drawn into terminal cells, so the cell is the tile β and a monospace cell is about half as wide as it is tall, which squashes a square 16Γ16 tile. The Display panel in the game bar adjusts font, font size, cell width (letter spacing), cell height (line height) and whole-pixel tile drawing while the game is running, and writes the result to the server's profile. The terminal is re-measured in place rather than rebuilt, so nothing on screen is lost.
PT Mono is bundled with the app, so it is the one font in the picker that
is certain to be there β the rest are whatever the OS provides. It is
ParaType's, under the SIL Open Font License; the
licence travels with it in src/fonts/. The terminal re-measures its cell once
a font has finished loading, because a cell measured against the fallback face
is the wrong width, and a tile drawn into it would be too.
"Whole-pixel tiles" draws each tile at 1Γ, 2Γ or 3Γ its native 16px art, centred in the cell, instead of stretching it. It needs a cell at least 16px in both directions, which is what the size and cell-width controls are for; below that it falls back to stretching, since a native-size tile would spill into the neighbouring column.
On macOS, Option is NetHack's meta key: Option+l is M-l, loot. Two things
get in the way, so the chord is intercepted in src/lib/keys.ts and the byte
written directly:
- Option composes characters by default, so Option+
lreaches the server as¬. - xterm.js's own
macOptionIsMetasendsESCthen the key. NetHack reads that as a meta command only when the player hasOPTIONS=altmetain their.nethackrcon the server, which this client cannot set for them. Without it,ESC lcancels and then walks east.
What NetHack actually wants is the ASCII code with the eighth bit set β
M(c) is 0x80 | c in cmd.c β which tty_nhgetch reads unchanged with no
server-side option involved. That is a byte no UTF-8 string can carry (encoding
U+00EC would put two bytes on the wire), hence the separate ssh_write_bytes
command. The physical key is what is read, not the character macOS composed
from it.
The public servers do not authenticate players over SSH. Everyone connects as a
shared game user (nethack@nethack.alt.org) and dgamelaunch then asks for the
game account inside the terminal. So:
- The SSH user in a profile is the shared account, usually
nethack. - The game account username/password is what auto-login types at the in-terminal prompt.
- The password is stored in the OS keychain (Keychain / Credential Manager / Secret Service), never in the config file. There is a test asserting the config file never contains it.
Auto-login answers the dgamelaunch menu, the username prompt and the password prompt, then stops. It deliberately does not pick a game from the post-login menu: those menus differ per server and version, and guessing wrong would start the wrong game.
Two things about that menu are worth knowing, because both are invisible until you look at the bytes:
- It contains no newlines. dgamelaunch places every entry with
ESC[8;3Hl) Login ESC[9;3Hr) Register new user, so with the escape codes stripped the whole screen is one line. Matchingl)at the start of a line never fires against a real server. - A rejected password says nothing. nethack.alt.org simply redraws the "Not logged in." menu. Watching for the words "login failed" would wait forever, so the menu coming back after the password is submitted is what counts as a rejection, and the account name appearing is the confirmation.
The status bar distinguishes the two logins: the SSH connection is the shared account, and "Logged in to the game server as β¦" is yours.
A first run with no config file at all starts with nethack.alt.org and hardfought.org already listed, each pointed at a tile sheet matching the NetHack line that server runs. Deleting every profile is a choice, not a first run, so they are not handed back.
Host keys are trusted on first use and recorded in ~/.ssh/known_hosts. A key
that changes is a hard failure, not a prompt.
Profiles live in profiles.toml under the OS config directory, in a folder
named for the bundle identifier (io.statico.nethack-tiles), which also names
the keychain entries.
Scan report Β· 2026-09-11
- β Prohibited terms or links
- β Repository eligibility
- β slopscore.md paperwork
- β Content policy
- β Risk review
0 comments
log in to comment.