This is the first entry in a build log for The Lot, which is a Windows application where the software you already pay for hangs on the walls of a 3D world you can walk around. It is not released. There is a private playtest behind a password, given to a handful of friends, and the page describing it says exactly that. Nothing here is a launch announcement and there is nothing for you to download.
What there is, and the reason to start the log here rather than with the pretty parts, is a fortnight of work on the least glamorous question a solo project ever faces: what is the difference between a build that runs on my machine and a build I can hand to somebody else? The answer turned out to be five separate mechanisms, four of which exist because something nearly went wrong. If you have ever shipped a desktop binary to a person who is not you, this is the part nobody writes down.
One: The Version Comes From Source, the Build Comes From Disk
The packaging script names its output after the version constant in the source tree. The build it wraps is whatever is currently sitting in the build folder. Those are two different things, and nothing forces them to agree.
That very nearly shipped. The version constant was bumped while the player on disk was three days old, which means the script would have cheerfully produced a zip labelled with a version number, containing a build from a fortnight earlier, and handed it to somebody for testing. The script's own docstring now records the shape of the problem: the version on the zip comes from source, not from the build, so a stale build gets a fresh label and nobody can tell by looking. Not the recipient, not the filename, not the page it downloads from.
So the packager now refuses to run when the newest source file is meaningfully newer than the build.
Two: The Executable's Timestamp Is a Liar
The obvious way to date a build is to look at the executable. That is wrong, and it is wrong in the direction that wastes an afternoon.
Unity leaves the main executable untouched when a rebuild produces byte-identical output. A rebuild left the exe carrying a date three days earlier while writing a fresh assembly next to it. A staleness check reading the exe would have declared a perfectly current build three days out of date and refused to package anything, forever, with no obvious way to argue with it.
The rule that came out of it
A build's age is the age of the newest file in it, not the age of the thing with the build's name on it. The same logic applies to any artefact assembled from many parts: date the collection, not the label.
Three: Ship by Hash, Not by Filename
The app embeds a browser engine so it can put real web pages on in-world screens. That engine comes in more than one build, and they are not interchangeable for licensing reasons: one includes proprietary video codecs and one does not. They have the same filename.
A check that trusts the filename here is no check at all, so the packager takes a SHA-256 of the library in the build tree — deliberately, because those are the bytes that would go into the zip — and compares it against two known hashes. Three outcomes, all of them explicit:
- The codec-free hash: ship it.
- The codec-carrying hash: refuse, unless a flag is passed that asserts a licence exists. The script prints a warning when that flag is used, and the warning says the flag is a statement rather than a workaround.
- Any other hash: refuse outright, and say that neither known variant matched, so the hashes need re-deriving before anything ships.
That third branch is the one worth copying. A verification step that only knows how to say “good” and “bad” will eventually be handed something it has never seen — a dependency upgrade, usually — and if it guesses, it guesses in whichever direction the code happened to be written. Refusing on the unknown case costs one branch and removes a whole category of silent wrong answer.
There is a deliberate piece of friction here too. Rebuilding copies the working library back over the build tree, so a swapped file reverts and the gate fires again on the next package. That is annoying on purpose. A check you can permanently satisfy by editing one file once is a check that stops being true the moment somebody forgets.
Four: The Exclusions Were Found by Looking, Not by Thinking
Before any of this, the honest question was: what is in my build folder that should not travel? The temptation is to reason about it from first principles. What actually worked was opening the folder and reading it.
Three things came out, and only one of them was predictable:
- A config directory holding my own pinned applications and window layout, written automatically whenever I ran the player from that folder. Shipping it would have handed a friend a copy of someone else's workspace and skipped the first-run setup that exists precisely to be exercised by a new install.
- A logs directory. My session, not the recipient's.
- A debug-information folder whose name literally ends in the words do not ship, which the engine generates and which I would never have thought to look for.
The first is the interesting one, because it was not junk. It was a real, working config file doing its job. It only became a problem at the moment the folder changed hands.
Five: A Product Default Is Not a Development Default
Two settings had to change specifically because the build was going somewhere else.
A file-driven command channel used during development shipped enabled, which meant a build handed to a stranger would sit there watching a file for instructions. That is fine tooling and an indefensible default on somebody else's machine, so the shipped default is now off and it stays on in my own install through a personal settings layer, which is where it always belonged.
Separately, the pre-package check installed global keyboard hooks, because it exercises the same code the app uses. Running a packaging gate while its author is typing meant a background process quietly eating keystrokes for the whole of its run. It now runs hook-free.
The thread running through all five
Every one of these is the same mistake in a different costume: something that is correct in the place it was written and wrong the moment the artefact leaves the machine that made it. A config that is yours. A default that suits a developer. A filename that means one thing on your disk. None of them are bugs. They become bugs at the boundary.
Why the Interpreter Is Embedded Rather Than Copied
One more decision, because it is the one people ask about when they hear a desktop app ships a Python runtime.
The obvious approach is to copy the development virtual environment. That was measured and rejected: the working environment's packages came to 2.4 GB against a ~660 MB requirement closure, because package managers never prune, so the environment carries everything the machine has ever needed rather than everything this app uses. The shipped runtime is therefore installed fresh from the requirement list, pinned by constraints generated from the working environment, so the shipped versions are the tested versions rather than whatever resolved that day.
Freezing into a single executable was also rejected, for a specific reason rather than a stylistic one: several dependencies carry native libraries and data files that a freezer has to be taught about individually, one hook at a time. A plain interpreter sitting next to plain packages has nothing to teach. It is made relocatable and isolated so a stranger's stray Python installation cannot leak into it.
And one thing ships that could have been downloaded on demand: the speech model, at 486 MB. A silent several-hundred-megabyte download the first time somebody presses a key reads as a hang, and the feature is supposed to work without a network. That is most of why the installed footprint is what the page says it is: about 4 GB.
What This Cost, and What It Bought
Roughly two days, none of it visible in the product. Nobody playing the thing will experience the hash check or the staleness guard. What it bought is that the zip on that page is the build I think it is, containing what I think it contains, with none of my own workspace inside it — and that I know those things because a script refuses to proceed otherwise, not because I remembered.
That is the whole argument for doing this before a wider audience rather than after. The failure modes above are all quiet. Every single one produces a zip that looks correct, downloads correctly, and runs. You find out later, from somebody else, in the least useful possible way.
Next time: what happens when you put a real browser engine on a wall in a 3D world and then ask it to stay at sixty frames a second.