We don't trust the score you send us
Same Seed is a garden. Twelve days, nine plots, thirty-six moves, and a number at the end. The number in your corner is not the one we display. We display the number our server recomputes, move by move, from the list of things you did. I built it that way for a reason I was not proud of at first: I was planning for someone to fake a score before there was anyone to fake one.
Twelve days. Nine plots. Thirty-six moves. That is the whole game.
A leaderboard is a promise
A daily board means everyone plays the same garden. Same soil, same weather, and the same days turning out dry or kind. That is the whole point of a daily board, and it is also what makes the score mean anything you can compare it to. The moment you put a board like that next to a list of other people's numbers, the list stops being a list. It becomes a promise that the number at the top is a real one.
I did not want to make that promise. And then I made it. There is a daily run, and there is a place on the site to watch the top run, move by move. The second part is the part I actually like, because it shows how the person at the top played rather than just how high they scored. It depends entirely on the first part being true. If the number at the top is a number somebody typed in, the whole feature is a frame over a lie, and I could not ship a lie I knew about.
So the server replays you
The client sends the actions, not the score. Plant plot three. Water it. Cut the daisy. Dig up the mistake. A log of things that happened, and nothing else. Our side ignores whatever number a player attached to it. We take the seed, grow the same puzzle, apply the same actions through the same rules, and the score falls out of that. If the actions were legal and the log was honest, the score we compute is the one you earned, whether or not you told us.
This part was easy. The rules already lived in a file with no canvas and no DOM, because we run the game headless for the tests, so there was a clean place for the server to reach in and run a season without touching a screen. I reused it. That was the easy week.
Easy, except for the last bit
Then I found the part that was not easy. The browser is one JavaScript engine. The server is another, Node. Both are supposed to do the same arithmetic, and for the most part they do. But floating point is IEEE-754 in both of them, and the order in which two engines fold a long sum is not a contract. A score that is right on a phone can come out one point wrong on a server, and a score that is off by one point is a wrong score, the same way a recipe off by a gram is a wrong recipe once you are the one measuring.
I never watched it actually happen. I did not sit down and see a browser and a server disagree. I found the risk by reading the code and deciding I did not want to bet a leaderboard on the two engines happening to agree. That is a strange thing to worry about, a failure I never saw, but it is the same instinct that made me write the headless tests in the first place. I would rather remove a possibility than argue about how likely it is.
So the fix was to stop using floats in the places that matter. Growth is tracked in integer thousandths, and a flower blooms at 100,000. No pow, no exp, no sin, no sqrt anywhere in the math that decides a score. Integers do not drift by a last bit between engines, because there is no last bit to drift. That is the whole trick, and I am a little embarrassed to describe it, because the description sounds trivial and the reason it took me a while to land on it was not.
Floats drift. Integers do not. That is the entire argument, and it took a week to believe it.
One day of growth
Here is a single day for a single plant, straight out of the rules file.
per day = round(100,000 / days to bloom)
gain = round(per day x weather x soil x moisture / 1,000,000)
Weather runs from 70 on a dry spell to 120 on a clear day. Soil is 125 when the flower sits on the ground it likes and 70 when it does not. Moisture is 100 by default and 104 when the plot is well watered. You multiply the four together, divide by a million to bring those per-mille factors back down to scale, and round to an integer. Every plant on the board does this once a day for twelve days, in your browser and on our server, and both of them land on the same number. The division by a million felt like a typo the first time I wrote it. Four numbers, each carrying its own fraction of a thousand, and the product needs a matching little denominator to come back to size. It is not a typo. It is just what four integer multiplications cost instead of one float one.
The test that cannot agree with a bug
The suite that guards this is the one I trust most, which is unusual for me. It plays 120 random seasons, then replays every one of them and checks that the replayed score is exactly the live score. All 120 match. It also checks that every single score is a whole number, which is the property that is supposed to make the whole thing safe in the first place.
Those two checks could both pass while something is quietly wrong. If the growth math drifted, the live run and the replay would drift the same way, because they use the same code. Two things computed the same way will agree even when they are both wrong. So the suite also carries fingerprints: three fixed seasons, each with a fixed score for four different ways of playing it, written down by hand in the test. Change the math and those numbers move on the first run. There is no agreement left to hide in, because one side of the comparison is a number I typed.
That is the idea I keep coming back to. A test that compares the program to itself will bless any drift, however small, because the drift is on both sides of the equals sign. A test that compares the program to a number somebody measured once and written down will not. The suite has 215 checks and all of them pass, but the fingerprints are the only ones that could not have passed on a lie.
The fingerprints are three seasons with fixed seeds, each played four ways, and each way has a score written into the test file by me, by hand, after I ran the game once and looked at the number and typed it in. That is a procedure with exactly one safeguard: I had to type it, so I had to look at it, so I would notice if it looked wrong. That is the entire point. A number you have to look at before you commit it is a number that survives the day you stop looking at the code that produced it.
The rule I did not want to ship
There is a version number in the file now, and it is 2. Bumping it is the price of changing anything that touches a score. Change the growth math and I have to re-measure every fingerprint by hand, because the old ones were measured under the old rules. The reason is that a replay has to keep verifying yesterday's runs under yesterday's rules. If it did not, the people who played a day ago would get their scores recomputed by today's code and then told the number they saw was the wrong one, which is a worse failure than a score that is off by a point.
I do not love this part. It is bookkeeping for a garden game, a version number and a handful of hand-measured scores I have to remember to re-measure. It is the kind of thing a one-person studio does a week too late, after the change has already gone out. But the alternative is a leaderboard where the number at the top is a number somebody decided, and I would rather do the bookkeeping now than explain that later.
I keep thinking about the version number, because it is the one part of this whole thing I designed to be annoying to me on purpose, and I am not sure I have ever done that before. Everything else in the game is a compromise I made and then tried to make feel like a choice. This one is a rule that makes my own future self's job harder, on a day when I am tired and the garden looks fine and nobody is asking for it. The only reason I will keep it is that I wrote it down in a file with a comment about what happens if I change the math.
A rule that only matters when you are not paying attention is the hardest kind to write.
Write the rule on the day it costs you something.