Multiplayer game, live in production
The server decides, the browser only draws: one deterministic simulation that runs identically on the production machine and inside the Chrome of someone playing alone.
Fable Football is a 2D football game where each person controls a single athlete, not the whole team. Two requirements pulled in opposite directions: in a competitive game the client cannot be the authority over anything, and at the same time training mode had to work with no server at all. The way out was to take the rules of play away from both sides and put them in a third place.
- 60 Hz on both sidesSimulation and state broadcast
- noneRuntime dependencies in the simulation package
- 44, counted by the loop itselfDeterministic batteries at the deploy gate
- 154 green, 25 mutants killedDuel ladder assertions, with mutation testing
- 62, on demandProbes that open the game in a real Chrome
- 93.8% down to 74.1%, monotonicPlayer win rate from level 1 to 11, measured
- 11, between 1.50 m and 2.20 mAttributes derived from the chosen height
A client that computes is a client that cheats
The game runs straight in the browser, with no install and no download: the product pillar is reaching fun in under a minute, which rules out any architecture that needs an installer, an app store or a slow lobby. Each person walks onto the pitch as one athlete: the run, the misplaced pass and the goal are theirs, not those of eleven puppets steered from above.
The constraint that governs everything else arrived with the competitive promise. If the browser gets to decide whether the ball crossed the line, someone will rewrite that decision, and the real problem is not the lone cheater: it is the community that dissolves once nobody believes the scoreboard any more. In a game where merit is individual, a scoreboard without trust is worth nothing.
There was, however, a second requirement pulling the other way: the same game needed a training mode that worked with no server at all, with zero latency and no cost for a machine left running. Central authority on one side, local execution on the other: both at once, and without two sets of rules ageing in parallel.
One simulation, in a package that knows nothing about the screen
The structural decision was to pull the rules of play out of both the server and the client and isolate them in a package of their own. It is roughly fifteen thousand lines of TypeScript across fourteen files, and the package declares not a single runtime dependency: neither Phaser, which draws, nor Colyseus, which handles the real-time rooms, gets in there. Physics, goalkeeper, offside and fouls do not know a screen exists.
The server runs that simulation at 60 Hz and broadcasts state at the same pace: the step is one sixtieth of a second and the sync rate is exactly that value, the 16.67 milliseconds that match the tick. The browser sends intent, never outcome: where I want to run, how long I held the shot. Whatever validates each touch on the ball is the process on the other side.
Ball flight physics has a single function, and it is the same one on both sides: the aim line the client draws while you charge a shot calls exactly the code the authoritative step will execute. There is no second copy left to drift over time, which is the classic way a networked game starts lying on screen: a line promising a curve the server never makes.
Offline mode as the proof, and the bug it exposed
Training runs that same simulation entirely inside the browser, with no room and no network: the connection is null on purpose, and the same step the server would have run produces the same events, the same notices and the same sounds. It is the cheapest proof there is that game logic has a single source, with two of them, the modes would drift apart within the first week of physics changes.
The trap showed up right after, and it is the most transferable lesson in this project: the deterministic simulation batteries went green while the scene was broken. In training the athlete stayed pinned at the centre circle and the camera did not follow the ball, because three of the client's aim and camera functions still gave up when there was no room, and in training, having no room is the design, not the failure.
The fix was making those seams aware of offline mode, and the lesson became a mechanism. We now have probes that open the game in a real Chrome, click the buttons, play, and measure what the battery cannot see: whether the athlete moves, whether the camera follows, whether the console logged an error. There are 62 of them today, run on demand outside the deploy gate, and every class of bug that escapes earns one of its own.
Determinism stops being a curiosity and becomes a gate
Because the simulation is deterministic, the same match with the same seed produces exactly the same result. That stops being an elegant property and becomes a tool: recorded scenarios run on every change and flag a one-pixel deviation, in a kind of software where the classic bug only shows up while playing.
There are 44 batteries at the deploy gate, and that number is counted by the loop itself, never written into the closing message. The old line carried a hardcoded "41", and the house rule is that a quoted number ages in silence: anyone joining the list without editing the sentence would make the log announce 41 while measuring 42. The list became the only source of the total.
Before it became a loop, the gate was one command per line. The first red aborted the block and the dozens that followed never ran, so each push revealed one problem instead of all of them. Today they all execute, each failure prints the tail of its own log, and the step only fails at the end, with the complete list of what broke.
The harshest measure, though, is mutation testing. The duel ladder test carries 154 assertions and kills 25 mutants: the harness breaks the rule on purpose, one break at a time, and demands that some assertion turn red. A test that stays green with the rule broken is not a test, it is decoration, and only mutation tells the two apart.
Difficulty measured, not estimated
The duel ladder is a staircase of rivals in the one-versus-one mode: four towers of eight opponents, and the next tower only unlocks after the previous one is cleared. Difficulty, however, is neither per tower nor per step: it is tower plus step minus one, which yields eleven levels rather than thirty-two. The total is derived in code, never written down: hardcoding "11" there would make the table lie the day the grid changed size.
The target we were given was a curve, not a feeling: a 95% player win rate at the first level, dropping two points per step down to 75% at the last. We measured it with a factorial panel of 36 plausible player policies, and the final ladder delivers 93.8% at level 1 and 74.1% at level 11, monotonic across all ten pairs and within 1.3 points of the target along the whole ruler.
What keeps that number honest is a bridge between the game and the measurement: the harness loads a copy of the table and the test compares the two, field by field. Without it, the word "measured" starts describing a rival nobody plays, which is how every measured balance rots. And we wrote down the bias that does not go away: the ruler is a bot that drives straight at the goal, while a human dribbles, it is a playtest starting point, not the real player's rate.
One choice from the player, eleven numbers
Whoever creates an athlete chooses one thing only: height, from 1.50 m to 2.20 m. From it the eleven attributes are derived continuously and deterministically, with no free point allocation, and the same pure function that feeds the simulation feeds the slider on screen: the ruler the player sees while creating the character is literally the one they will play with.
The complaint that came back from playtesting was that the tall player could not compete with the short ones, and intuition pointed at a lack of body play. We measured, and intuition was one step away from the right place: the shielding arc already belonged to the tall one. What did not belong to him was carrying speed: 84.0 px/s while carrying, against 166.3 px/s of the shortest one simply running. In 48 of the 49 height pairs, the carrier could not escape his marker even at a sprint.
The fix was a single anchor point on the ruler, and the effect is graduated by construction: plus 1.2% at 1.50 m, plus 6.8% at 1.85 m and plus 17.9% at 2.20 m, with carrying at the top rising to 99.0 px/s. We measured again on a five-by-five height matrix with 108 matches per cell, to confirm what the change did not do: the short player is still ahead on possession and on shots. The tall one stopped being uncarryable; he did not become the strongest.
What can be sold, and what has no price
Monetisation changed pillars by product decision: the game now assumes a light, balanceable pay-to-win, in which a paid item may grant an attribute advantage. The second word is the one doing the work (balanceable) and it did not stay in the pitch deck: it became an executable lock in the catalogue, enforced by code.
An advantage in flat attribute points is forbidden, and the reason is a measured number: the same "plus N" is worth up to 4.04 times more to one height than to another, so no admin ceiling can make it fair across body types. Only the percentage form gets through, because it yields the same proportion for everyone by construction and the admin panel tunes the value without a deploy.
And there is a category that still has no price at all: perception advantage. Seeing better where your opponent is looking is information, not force: there is no "minus 3% of seeing" to charge in exchange, and that is why it cannot be balanced with any number. The gate measured 32 directions across 5 regimes with the hair painted in skin colour, the worst possible case, over two byte-identical runs; three haircuts failed and stayed out of commerce, with their geometry left intact inside the game.
The lock is enforced at both ends: in the write validator, on the server, and in the read validator, on the client. The catalogue is data editable from the admin panel, and a lock only on writes would be bypassable by any row already sitting in the database before the rule existed.
The game is live with online matches, offline training and the duel ladder, on a machine in São Paulo that scales to zero when nobody is playing and spins up an ephemeral machine to apply migrations before the new version takes any traffic. A single instance, on purpose: room state lives in memory, and two machines would put players on different pitches without ever seeing each other. The rules of play, those still have a single source: proven by the server and the browser at the same time.