Train to TikiTown is an art project for the ReFeu art festival.
  • Go 97%
  • Shell 1.7%
  • HTML 1.1%
  • Makefile 0.2%
Find a file
2026-10-04 12:23:06 -04:00
.claude voiceprep: pronounce.toml for names the voices misread; name pieces named by their words; bilingual voice default 2026-10-03 18:21:53 -04:00
cmd biome boundary hitch: route keeps each pairs colour gap, stations test the distance before Skip 2026-10-04 12:23:06 -04:00
docs phone: horn in the header, tap for a blast, twice for the tunnel toots; J key 2026-10-03 23:24:25 -04:00
internal biome boundary hitch: route keeps each pairs colour gap, stations test the distance before Skip 2026-10-04 12:23:06 -04:00
music alpine: a Rocky Mountain valley biome, parked off the route, seen with -biome alpine 2026-10-02 14:34:47 -04:00
scripts soak: -s watches the running tiki.service and counts restarts; mean fps over real elapsed time 2026-10-03 19:06:50 -04:00
voice voiceprep: pronounce.toml for names the voices misread; name pieces named by their words; bilingual voice default 2026-10-03 18:21:53 -04:00
.gitignore conductor: all aboard, a wooden whistle and tickets please at the stations, his own mixer level, and make voice for the takes 2026-10-02 18:56:11 -04:00
go.mod add the operator's phone page and hold the screensaver off over D-Bus while the show runs 2026-10-01 23:51:18 -04:00
go.sum add the operator's phone page and hold the screensaver off over D-Bus while the show runs 2026-10-01 23:51:18 -04:00
local.mk.example voiceprep: station calls made as pieces and joined, takes named by their words 2026-10-03 14:32:54 -04:00
Makefile conductor: language and voice picked on the phone, both languages French first, every voice prepared whole 2026-10-03 15:06:00 -04:00
README.md soak: -s watches the running tiki.service and counts restarts; mean fps over real elapsed time 2026-10-03 19:06:50 -04:00

TrainToTikiTown

An endless, procedurally generated train ride through North American biomes, seen from inside the carriage. Built to run unattended for days on a projector as a live installation, tunable from the keyboard during the show, with an arrival at Tiki Town once every place has been visited, or on demand.

Ship date: 8 October 2026.

Running it

make test          # unit tests, no GPU needed
make build         # host binaries into build/
go run ./cmd/tiki  # opens a window and runs the startup checks

On the show machine:

make deploy                   # to the show machine named in local.mk (copy local.mk.example)

The two commands

cmd/tiki is the show. It renders the ride, graded through a mood, behind the carriage interior. There are two moods: realistic is the show look and flat is the ungraded reference you judge it against. The startup checks stay in the binary permanently — they catch failures that are otherwise silent. They prove the mood pipeline works by compiling a probe shader that uses the same integer hashing and clamped texture taps the real moods need, running it, and reading the pixels back to check the result.

./tiki -assert-only        # print the report and exit
./tiki -require-gles       # fail unless the context is OpenGL ES, the dialect the moods are tested in
./tiki -require-hardware   # fail unless acceleration is confirmed (show machine)
./tiki -fullscreen         # how it runs at the venue
./tiki -window=false       # no carriage, glass glare kept; the world fills the screen, for a painted or physical frame
./tiki -roll full          # how much the land tilts on a curve: half (default: the land at half, our own railway level), full (as a fixed camera sees it) or off
./tiki -mood flat          # start in a given mood: realistic, flat
./tiki -quality high       # detail profile: low, medium, high
./tiki -supersample 1      # high draws at 2x and averages down to smooth slanted edges; 1 turns that off
./tiki -biomes internal/biome/assets -dev   # edit biome TOML and see it reload
./tiki -biome "high desert"  # hold the route at one biome, for authoring it
./tiki -tiki               # book the Tiki Town arrival at startup, for demos
./tiki -golden 1           # pin the low sun anywhere on the route, for tuning it
./tiki -biome "tiki town"  # the destination itself; the route can never choose it
./tiki -gamma 1.1 -lift .04  # projector tuning for this run; the venue's own is kept in tuning.toml
./tiki -music music/.show  # play the albums make music prepared; -checkpoint keeps their places
./tiki -voice voice/.show  # the conductor's lines make voice prepared; found on its own when run from here
./tiki -check-moods        # every mood, both blend modes and the 2x averaging, read back off the GPU
./tiki -bench 30s -vsync=false   # frame times, with percentiles
./tiki -bench 20s -memprofile m.out   # where the heap goes; go tool pprof -alloc_objects m.out
make drawcalls             # prove the atlas has not split and draw calls are in budget
make showmachine           # measure this machine and say which -quality it holds
make soak SOAK="-d 72h"    # run it overnight and report whether it stayed healthy

Every key the show answers to is in the hotkey table — one list, the same one the show dispatches through.

-quality changes how much of the world is drawn, never what is in it. Objects exist when a hash of their slot falls below a threshold, so raising the density only admits things that were already there — nothing moves. That is what makes a screenshot from a laptop evidence about the show machine. Supersampling is the same: -quality high draws the scene at twice the output and averages each 2x2 block down, so slanted edges are smooth instead of stepped, and the train judges its detail in output pixels so the world drawn is identical either way.

Note what the checks do and do not prove. shader ran: true means the mood pipeline works; it says nothing about whether a GPU is doing it, because a software rasterizer passes identically. See Show machine setup for why that distinction is the one that matters.

cmd/fillprobe measures the GPU's effective fill rate, which is the number the whole render budget rests on, and says which -quality it can hold.

./fillprobe            # textured, at 1280x720; -quality high draws 2560x1440, and says so
./fillprobe -w 640 -h 360    # -quality low
./fillprobe -solid     # untextured, to separate texture cost from shader cost

Note 854x480 is not a step down on a 720p output: it is 1.78x the pixels of 640x360 and a fractional upscale. On a 1280x720 output the internal resolutions that upscale exactly are 1280x720, 640x360 and 320x180. -quality low is 640x360, so -quality low IS the lever — there is no separate emergency resolution below it except 320x180, which is very soft.

Show machine setup

The show machine is tikitrain, a laptop running Debian 13 Trixie with GNOME on Xorg: an i7-10750H, Intel UHD graphics and a GTX 1650 Ti Mobile (PRIME offload, NVIDIA driver 550). The show runs as the desktop user, user, and is started on the GTX through switcherooctl launch -g 1; without that it lands on the Intel GPU. Measured on 1 Oct: -quality high runs at a 2.7 ms median frame, six times inside the 60fps budget.

Packages

Ebitengine is pure Go and builds with CGO_ENABLED=0, but it opens the system graphics libraries at runtime. A missing library fails on the show machine at startup, not on the dev box at build time:

sudo apt install xserver-xorg xinit libx11-6 libgl1 libegl1 libgles2 \
  libxcursor1 libxi6 libxinerama1 libxrandr2 libxrender1 libxext6 \
  libasound2t64 mesa-utils

Plus the GPU's driver: for the GTX 1650 that is Debian's nvidia-driver, from non-free.

libegl1 and libgles2 are not optional and are absent on a minimal install. Ebitengine tries libGLESv2.so first and silently falls back to desktop OpenGL if it cannot load it, which would compile every mood in a dialect none of them has been tested in. -require-gles exists to catch that. mesa-utils gives you es2_info, which -require-hardware needs to confirm the GPU is doing the work.

The session

The show draws into the GNOME session of user, so that session has to come up by itself after a power cut, and must never blank or lock:

# /etc/gdm3/daemon.conf, under [daemon]
AutomaticLoginEnable=true
AutomaticLogin=user
gsettings set org.gnome.desktop.session idle-delay 0
gsettings set org.gnome.desktop.screensaver lock-enabled false
gsettings set org.gnome.desktop.screensaver idle-activation-enabled false
gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-type 'nothing'
gsettings set org.gnome.settings-daemon.plugins.power idle-dim false

The show also asks GNOME itself not to blank, lock or suspend while it runs (an idle inhibit on the session bus, the one a video player takes), so a reset setting cannot black out the wall. The settings above stay as the backup. The startup log says inhibit: holding off the screensaver when GNOME took the request, and /state reports "screen_hold": "held" for as long as it keeps it.

Over ssh there is no display. To run anything graphical by hand, borrow the session's: export $(systemctl --user show-environment | grep -E '^(DISPLAY|XAUTHORITY)='), then prefix the command with switcherooctl launch -g 1.

Verifying, in order

cd ~/tikitrain
G="switcherooctl launch -g 1"
$G es2_info | grep GL_RENDERER       # want the GTX — llvmpipe or Intel here means stop
$G build/tiki -assert-only -require-gles -require-hardware
$G build/tiki -check-moods           # every mood, both blend modes and the haze, read back off the GPU
$G build/fillprobe                   # the check that cannot be fooled
xrandr | grep '\*'                   # want 1280x720 at 60.00 on the projector
LAUNCH="$G" scripts/showmachine.sh   # which -quality it holds

The dangerous outcome is not a missing GPU, it is llvmpipe succeeding. llvmpipe advertises GLES 3.2, so if the GPU driver fails to load you still get an ES context, every shader still compiles, shader ran still says true, and the window still opens fullscreen. The only symptom is framerate, possibly not until the scene gets heavy. That is why -require-hardware treats an unconfirmable renderer as a failure, and why fillprobe is the real answer — tens of Mpx/s instead of Gpx/s means software rendering no matter what any string claims.

Running the show

The one-page version for whoever is standing in the room is docs/runbook.md — print it and tape it up. Its hotkey block is checked against the binary by a test, so it cannot quietly go stale.

The installation runs as a systemd service called tiki. It restarts itself forever and resumes the ride where it had got to. Everything below is for whoever is standing in the room.

Install it, once

make deploy builds, tests and copies everything to ~/tikitrain on the show machine: the binaries in build/, the scripts and the unit in scripts/. Then, on the show machine as user:

install -Dm644 ~/tikitrain/scripts/tiki.service ~/.config/systemd/user/tiki.service
systemctl --user daemon-reload
systemctl --user enable --now tiki

It is a user service, so it starts with the desktop session and needs no root. The session itself has to come up on its own — see The session. After that first install, every make deploy installs the unit again and restarts it, so a changed tiki.service takes effect; changes made with systemctl --user edit are drop-ins beside it and are kept.

The runtime packages are listed under Packages. The service file ships -require-hardware, which needs es2_info from mesa-utils to confirm acceleration and refuses to start without it — deliberately, because a software renderer passes every other check in the report. Verify with switcherooctl launch -g 1 build/tiki -assert-only -require-gles -require-hardware before enabling the service, not after.

Start and stop

systemctl --user status tiki
systemctl --user restart tiki    # the first thing to try
systemctl --user stop tiki       # the only thing that actually ends the show
systemctl --user start tiki

Escape twice quits the binary and systemd starts it again two seconds later. That is the whole point of the service, so ending the show means stop.

Is it healthy?

curl -s localhost:8080/health   # ok: 1284531 frames
curl -s localhost:8080/state    # what it thinks it is doing, as JSON

200 means frames are still arriving. 503 means none has for three seconds: the wall is frozen even though the process is alive and answering you. The watchdog catches that within 20 seconds and restarts it, so a 503 still there a minute later means restarting is not fixing it — go to Black screen.

/state also carries heap_bytes, heap_objects and gc_cycles. Over a long run the heap should be flat; a heap that climbs steadily across hours is the failure mode a multi-day installation actually dies of, and it is invisible in the picture until the moment it is not.

Before the show machine is trusted

make showmachine               # what it is, what it holds, which -quality to ship
make soak SOAK="-d 72h"        # leave it running; read build/soak-*/summary.txt after

Once tiki.service is installed, soak the show as it runs instead of starting a second one, which would fight it for the screen, the sound and port 8080. On the show machine, from ~/tikitrain:

systemd-run --user --unit=tiki-soak --collect -p WorkingDirectory=$HOME/tikitrain $HOME/tikitrain/scripts/soak.sh -s -d 48h
journalctl --user -u tiki-soak     # how it is going; build/soak-*/summary.txt at the end

It starts and stops nothing; a restart of the service is counted, and is a fail.

showmachine benchmarks boreal lake, the most expensive biome, and prints the -quality to put in scripts/tiki.service. Do that before trusting any number in this repo: every one of them came from the dev box. A pass for the soak is no health stalls or restarts, the heap flat within a few percent, no thermal throttling, and 60fps end to end.

Logs

journalctl --user -u tiki -f           # live
journalctl --user -u tiki -b -n 100    # this boot
journalctl --user -u tiki --since -1h | grep -iE 'silent|watchdog|fatal|llvmpipe|inhibit'

No sound

The show comes up silent rather than not coming up at all, so no sound is never why the wall is black. It checks for a device first and logs running silent: with the reason.

cat /proc/asound/cards          # the USB adapter should be here
journalctl --user -u tiki | grep silent

Plug the adapter in and restart. If instead the show dies on audio, add -audio=false — Ebitengine reports a device failure as an error out of the game loop, which ends the process, and with Restart=always that is a loop that never gets a picture up.

Music

Albums play under the train, each tagged for the places it suits. The show plays only what make music prepares; it never reads the original files.

  1. Put each album in its own folder on the dev box as music/<tag>/<album>/. The tag is a biome name (city, rust belt, great plains, high desert, coastal california, boreal lake, rainforest, alpine), tiki town, or any for albums that suit anywhere. Case, hyphens and underscores do not matter, so high-desert works too. Files can be .flac, .mp3 or .wav; they play in track-tag order, or in natural filename order when the tags are missing.
  2. make music converts them into music/.show/ (48 kHz 16-bit WAV), levels each album against the others at -16 LUFS (lifting a quiet album with a limiter where its peaks leave no room), and lists every problem: an unknown tag, an unreadable file, an MP3 without gapless info. Run it again after any change; it converts only what changed.
  3. If an album sounds too loud or too quiet, edit gain (dB, -12 to +6) in music/.show/<tag>/<album>/album.toml. make music keeps the edit.
  4. make music-deploy copies them to ~/tikitrain/music on the show machine and restarts the show, a few seconds of black. ARGS=--stage copies without the restart, and make music-swap puts a staged copy live later. A copy that did not finish is never swapped in.

How it plays: a track starts only if it will finish before the biome ends, unless that would leave more than music_max_silence seconds of just the train, in which case it plays and fades out over music_fade seconds before the biome ends. Each album keeps its place in music.toml beside the checkpoint, so it carries on where it stopped when its biome comes round again, across restarts too. music_generic in tuning.toml decides whether any albums only fill biomes with no album of their own (fill, the default) or also take turns in the ones that have (mix). The music level and pause are kept in tuning.toml like the volume.

Without -music, or with an empty or missing folder, the show runs as it always did, with just the train. The F1 overlay and the phone page show what is playing, or why nothing is. The operator's version is in docs/runbook.md, "Music".

Stations and the conductor

In about three biomes out of four the train stops at a station for 30 s to a minute (-stations, 0 to 1, sets the share). The conductor calls "All aboard!" and blows his wooden whistle as the stand ends, and "Tickets please!" once the train is under way. Along the way he points out the sights as they come into view, his line ending just as each appears: every egg (the cowboy, the strip club, the casino and the rest), and about half of the hay fields and landmarks, each kind once a visit and five minutes apart at least. Their lines are the look- ones in voice/lines.toml, named after the thing; one with no takes is skipped. His lines come from make voice; the whistle is synthesised and needs nothing.

  1. voice/lines.toml names his lines and their words. Recorded takes go in voice/<line>/ in any format ffmpeg reads, as many takes as you like; one is picked at random each time.
  2. make voice-placeholders fills any line with no recording from Piper (a venv in ~/tts-bakeoff/piper with the voice models beside it), as piper-* files. A line with any recorded take ignores them.
  3. make voice trims each take's silence, levels it to -16 LUFS as one channel (played on both speakers it is about 3 LU louder than an album), and writes voice/.show/ (48 kHz 16-bit mono WAV).
  4. make voice-deploy copies them to ~/tikitrain/voice on the show machine and restarts the show; ARGS=--stage and make voice-swap work as for the music.

Run from the repository, the show finds voice/.show without -voice; with -voice "", or a folder with nothing in it, he only blows the whistle. His level is the Conductor row of the mixer (F10, F11, -mix-conductor), and the music dips under him.

Changing a flag

systemctl --user edit tiki      # opens a drop-in, then restart
[Service]
ExecStart=
ExecStart=/usr/bin/switcherooctl launch -g 1 %h/tikitrain/build/tiki -fullscreen -require-gles -require-hardware -quality high -hud=false -checkpoint %S/tiki/checkpoint.toml -http :8080 -music %h/tikitrain/music

The empty ExecStart= is not a typo. Without it systemd adds a second command rather than replacing the first, and refuses to start.

Projector tuning, live

[ and ] move gamma, - and = move black lift, ; and ' the carriage's brightness against the view, 0 puts those three back. Together with the volume, mute and the mix (each of the train's sounds and the conductor, turned with F2 to F11 or the phone's mixer) they are the venue tuning, kept in tuning.toml beside the checkpoint (~/.local/state/tiki/tuning.toml under the service) and written the moment a key changes one. It is a file of its own, so a deploy or a discarded checkpoint never loses it. The steps for the room are in docs/runbook.md, "Setting up at the venue".

A flag given on the command line wins over the file for that run: -gamma, -lift and -mix-master. A checkpoint still overrides -mood and -speed, which are restored from where the show left off; to make one of those take effect, delete the checkpoint file first.

curl -s localhost:8080/state | grep -E 'Gamma|Lift|Carriage'

Black screen

In order. The first two are the usual answers.

  1. The projector: right input, and check it has not gone to sleep.
  2. systemctl --user status tiki. Stuck in activating, or restarting over and over, means it never got a picture up — it did not crash mid-show.
  3. loginctl list-sessions. No session for user on a seat means the desktop never logged in, which is the machine and not the show: check gdm's automatic login. The service starts with the session.
  4. journalctl --user -u tiki -n 50. A failed startup check or llvmpipe in the renderer line means the GPU driver did not load, and sudo reboot fixes the usual cause. If it comes back the same and you need something on the wall now, drop -require-gles -require-hardware in the drop-in: the show runs soft and slow, and that is a fault for the morning.

Hotkeys

Key What it does
1 realistic
2 flat
Up faster
Down slower
R back to cruising speed
B skip ahead to the next biome
U take the train through a tunnel
H sound the horn
C a level crossing just ahead
S stop at a station just ahead
M mute
. louder
, quieter
K music quieter
L music louder
P music off or on (keeps its place)
N next track
A next album
F2 clatter quieter
F3 clatter louder
F4 wind quieter
F5 wind louder
F6 horn quieter
F7 horn louder
F8 crossing bell quieter
F9 crossing bell louder
F10 conductor quieter
F11 conductor louder
[ brighter
] darker
- deeper blacks
= lift the blacks
' carriage brighter
; carriage darker
0 reset the projector tuning
F1 show or hide the overlay
T arrive at Tiki Town (press twice)
Esc end the show (press twice)

U is a tunnel on demand: twelve seconds of dark with the emergency lights sweeping past, coming back out in the same biome the train went in. Pressing it again holds the dark longer. It is refused during a Tiki Town arrival, where the dark would cover the one thing worth looking at, and on a sweeping curve, where it would cover the train. C is refused on a sweeping curve too.

Sweeping curves. About one and a half times a biome the line swings round to the right in a hairpin, about 240 degrees at an 80m radius, slowing to 10 m/s, and the train ahead comes round into the window for about half a minute. The log says sweeping curve at ...m a kilometre before each one, or as the braking starts if the speed is turned up, and the first one's metre at startup, for -shot-at. A Tiki Town arrival that would land on one is pushed past it, which can add a few minutes; the log says so.

Tiki Town on its own. The route deals the biomes like a shuffled deck: every eight segments plays each of them once, in a new order each time, with no look-alikes side by side. Once every biome has played a whole segment since the train last left Tiki Town (or since the show began), the arrival is booked exactly as T books it, so the train goes there every two to four hours. A T visit counts: leaving it starts the count again. The checkpoint carries where the count started. Only the show itself runs the schedule; -shot-at, -bench and the tests never head there on their own, and -tiki-schedule=false turns it off.

T and Esc need a second press within four seconds, and any other key cancels the arm. Nobody leaning on the keyboard can end the show or arrive at Tiki Town by accident.

From a phone. With -http :8080 (the service has it), http://<machine>:8080/ is a dark phone page with the same commands as the keys, bar ending the show, the overlay and the tuning reset, on three tabs (Ride, Picture, Sound; the Sound tab has the mixer). The F1 overlay shows the address. A tap goes through the same path as its key on the game's next frame, so Tiki Town still needs two. Taps are limited to ten a second and the biome skip to six, then one every ten minutes. There is no password; why is in internal/control/http.go. The operator's version is in docs/runbook.md, "From a phone".

The icon, a carriage window at golden hour, is drawn as SVG in internal/icons: the phone page's favicon, its home-screen icon and web app manifest, and the show's window icon. After editing an SVG, scripts/icons.sh remakes the PNGs (it needs chromium and ImageMagick); commit what it writes.

This table is transcribed by hand. ./tiki -hotkeys prints it from the list the binary actually dispatches, which is the copy to trust.

Layout

cmd/tiki             the show binary
cmd/fillprobe        GPU fill-rate measurement
internal/gpuassert   startup checks: ES context, hardware acceleration, shader pipeline
internal/hash        Squirrel3 — every procedural decision derives from this
internal/noise       value noise and fBm built on the hash
internal/track       curvature, heading and position; the ONE curvature integration
internal/scene       projects the route into triangles; flora.go is the silhouettes
internal/frame       the draw list: vertices, indices, batches, overdraw stats
internal/render      Backend interface (five methods) + the null backend
internal/interior    the carriage: a window bay lit by the world outside and its own lamps
internal/mood        the Kage post shaders, and the only clock they can reach
internal/quality     detail profiles: one world, rendered thin or thick
internal/biome       what part of the continent this is; route sequencing and transitions
internal/station     where the stations are, and the timed stop shared with the Tiki bar
internal/voice       the conductor's takes, read into memory at startup
cmd/voiceprep        makes the Piper placeholders and prepares the conductor's takes
internal/oklab       perceptual colour blending, for transitions and the tunnel threshold
internal/render/ebitenbk   the real backend

Authoring a biome

A biome is one TOML file in internal/biome/assets/. It says what a place is — the colour of the light, the shape of the skyline, and what grows beside the track. It says nothing about time of day or mood; those are separate axes on purpose.

name       = "rainforest"
silhouette = "spires"      # adjacency classes: two neighbours may share neither,
colour     = "green"       # or the transition is wasted on two places that look alike

[palette]
sky_top = "#99a8ad"        # trunk and dead are optional and derived from canopy
...                        # when left out; everything else is required

[terrain]
ridge_wave  = 0.7          # keep near 1 — above about 1 there is less than one noise
                           # period across the window and the skyline becomes one hump
terrace     = 0.85         # flat tops with hard sides: mesas, bluffs, stepped skylines.
                           # `ridged` does NOT do this — it makes sharp peaks, which is
                           # a mountain, and a mesa is the inverse
shore_depth = 900          # water from the horizon in to here, land in front of it
far_shore   = 1400         # a lake's or a river's far bank: land beyond it; left out, water to the horizon
glints      = 0.6          # how much the water sparkles, 0 to 1
surf        = 0.0          # how much surf breaks on the shore, 0 to 1
canopy_density = 0         # per-group: an empty treeline with normal grass on the cess
fence       = 1.0          # a ranch fence along the line, in stretches; 0 or left out, none

[flora]
canopy   = ["fir:6", "cedar:3", "snag:1"]   # the treeline, 547m to 48m
scrub    = ["brush:4", "sage:1"]            # 32m and 21m
lineside = ["sedge:3", "grass:2", "brush:1"] # the cess, 16m to 11m

[station]
names = ["Fern Hollow", "Drizzle Creek", "Mossy Glen"]  # one picked for each station's boards
roof  = "pitched"          # the station house's roof: pitched, steep or flat

Station names are written as they should read; the boards paint them in capitals. Only A to Z, spaces, ', - and . have letters, and a name with anything else refuses to load.

Numbers after the colon are relative weights and may be left off. An empty group means nothing grows at that distance. A misspelled plant refuses to load and lists the real names; go doc traintotikitown/internal/scene SpeciesNames has the set.

Zero means two different things in [terrain], depending on the knob. For quantities — ridge_height, the densities — zero means zero, so a prairie can ask for a dead-flat horizon and an empty treeline. For scales — ridge_wave, scatter_height, scatter_width, clump_wave — zero is degenerate rather than meaningful, so it means "not mentioned" and leaves the base table alone.

scatter_height and scatter_width change the aspect, not just the size. They are separate multipliers on a band table that is already shaped. 0.45 by 1.3 drew the nearest band 22.7px tall and 72.1px wide, which is a hoarding lying on the ground and no silhouette survives it. Render it and look before blaming the plant.

Work on one with the files live, no rebuild:

./tiki -biomes internal/biome/assets -dev      # reloads when a file changes

docs/gotchas.md collects the things that will bite — silent failures, the fill budget, shader traps — grouped by when they first matter.

Take a look at it without standing in front of a projector. Shots go through the mood pass at the ship resolution, so what lands on disk is what the room will see:

./tiki -shot out.png -shot-at 7650 -mood flat   # one frame, 7.65km along the route, ungraded
./tiki -tiki -shot bar.png -shot-at 2105 -shot-clock 17   # standing at the Tiki bar, 17s into its script

More packages land as the renderer does. The full plan — day-by-day schedule, cut list, render budget and the decisions behind all of it — is in .claude/plans/implementation-plan.md.

Rules that do not get renegotiated

These are cheap now and expensive to retrofit, so they go in from the start.

Positions are float64 until the last moment. float32 precision crosses 1cm at 84km, which is about an hour of travel. So s is float64 and ds := float32(sObj - S) narrows after the subtraction. That one line is the floating origin.

Shader time wraps at 64 seconds. A float32 time uniform at 60fps runs 1.9x fast by day three and freezes entirely at 6.07 days. Every periodic effect gets a frequency that divides 64. No shader has a periodic term today, so the rule and the prelude's wave() are waiting for the next thing that pulses or sweeps; a test fails if a mood reaches the raw Time uniform instead.

Nothing this program does allocates per frame, and that is tested — but the collector stays on. Profiled: 360,179 of 375,353 allocations in a twenty-second run come from Ebitengine and purego, five come from here, and those five run once at startup. It is purego boxing arguments through reflection on every call into libGL and libX11 — Ebitengine's Linux backend, which is purego whether or not cgo is enabled (a CGO_ENABLED=1 build profiles the same). So debug.SetGCPercent(-1) must never be installed — it would turn ~480 MB/day of unavoidable engine churn into an out-of-memory crash on day three. GOMEMLIMIT is the net. See "Running for days" in docs/gotchas.md.

Exactly two render passes. The scene into an offscreen at the profile's internal resolution, then one shader pass that grades it and lands 1280x720 on the wall. -quality low renders 640x360 and lets the present blit do the 2x upscale; medium is already at output resolution, and high draws twice the output and the shader pass averages it down.