Bolting a League Mode onto a Football Game: Three Weeks of Pitfalls

The game has no career league, so I built one outside it: fixtures, transfers, growth and the board all live outside, and the game only plays the matches. These are the hardest problems of those three weeks: making the game reload its data, compressed slots we couldn't fit back into, newgens without faces, saves tied to the database, and a formation bug I fixed today.

· 12 min read

Bolting a League Mode onto a Football Game: Three Weeks of Pitfalls

My favourite thing in football games used to be the career mode: take over a club and play it season after season. The game I play now doesn’t have one. What’s left is card packs and online matches.

So I decided to build my own. The idea is simple: the game is only the match launcher. It plays the matches and supplies the players and visuals. The league system (fixtures, tables, transfers, growth, the board, saves) all lives outside the game, in a local web app.

From the first design doc to the first version someone else could play took three weeks. Here is what it does now:

  • leagues on the real calendar, plus continental competitions and cups;
  • injuries, suspensions and fitness, which the AI uses to pick its starters and rotate;
  • players grow, decline and retire, and academies produce newgens;
  • transfers, contracts, wages, board targets, and getting sacked mid-season;
  • dressing-room morale and a manual tactics board.

It plays like FM and an action football game stitched together.

This post isn’t about features, though. It’s about the pitfalls. Each of the problems below kept me stuck for more than a day.


Two rules I set myself first

  1. Use only the game’s own data. Players, teams and squads come from the game’s database package. Badges and portraits are unpacked from the game’s containers. Match data comes from the post-match stats export. No external player databases, and no face or badge packs from the internet.
  2. What you see in the game wins. When an analysis disagrees with what shows up in the game, trust the game.

The second one sounds obvious. You’ll see below how many times it saved me.


Pitfall 1: the game reads its data only once, at startup

Everything that happens in the league (injuries, transfers, growth) has to show up in the game eventually. The AI opponent shouldn’t field a suspended player, and the players you sold shouldn’t still be in your squad.

The problem: the game reads its database once at startup and never again. Having to restart the game after every change is a terrible experience.

My first idea was to swap the data out as the game read the file. I wrote the hook and it logged activity. Later I checked: 303 reads, every one a SKIP, zero replacements. The game reads that file asynchronously, and that code only handled synchronous reads. The feature had never worked. Nobody had noticed.

So I took a different route, in three steps:

  1. A shadow database. When the game starts, the host copies the database to a shadow file, and when the game opens the database it gets the shadow instead. The real file isn’t touched, not one byte.
  2. In-place writes. While the game is running, only a few tables inside the shadow are changed. The directory and table lengths stay the same, because the game reads at the offsets it cached at startup.
  3. A forced reload. Static analysis turned up the game’s own “reload the database” routine, the one the login step uses. I hooked the per-frame update and call that routine on the game thread when a request comes in.

Measured: the reload starts 7–9 ms after the request and finishes in about 920 ms. No restart, no login, no cutscene. In a friendly, Bournemouth’s starting XI matched the change in the shadow exactly.

Lesson: a feature showing up in the logs doesn’t mean it works. Count how many times it actually took effect.


Pitfall 2: compressed slots, and the 235 bytes that wouldn’t fit

The rule “write in place, never grow” bit me twice.

The first time, the player (me, really) played two seasons with the game closed and sold quite a few people. When I started the game, the sold players were still in the squad, though their ages and abilities were correct.

The log caught it in the act: the packed squad table was 196,982 bytes and the original slot was 196,747. It was 235 bytes over, so the whole table was skipped. Abilities and ages live in a different table, which is why only the squads were wrong.

The cause was how transfers were written. Each one deleted a row and appended a new one at the end of the block. Two seasons meant 1,611 moves, which shuffled the back half of the file around until 30% of the bytes had changed. zlib could no longer compress it back to the original size.

The fix: incoming players take over the hole the outgoing player left, inheriting the shirt number and roles in place, and only a net gain or loss touches the end of the block. I also added a fallback: if the default compression doesn’t fit, try a few zlib settings and keep the smallest.

The second time was stranger. The appearance table didn’t fit back into its original slot even unmodified. The original files were packed with a stronger compressor than zlib:

CompressionSizeOriginal slot 1,183,672
zlib level 91,213,35929 KB over
libdeflate level 121,185,3151.6 KB over
zopfli, 5 passes1,183,362fits with 310 bytes to spare, takes 4–5 s

zopfli does squeeze it in, but the next change would push it over again, and it adds two dependencies. The real fix was to stop competing on compression ratio. The game now copies its shadow from a “shadow source”. In that file, the tables we write get re-packed ahead of time with our own compressor and padded with zeros, leaving 10% headroom. The sizes in the directory include the padding, and the game accepts it.

Lesson: when editing in place inside someone else’s format, first make sure you can write the original back. If you can’t even do that, changing anything is moot.


Pitfall 3: newgens without faces

Players retire, so the world needs newgens. Otherwise everyone is a veteran within a few seasons. (The first version did exactly that: players aged 18–20 went from 1,430 to 68 in four seasons.)

Where do newgens go? The obvious idea is to reuse a retired player’s ID. But 3D real faces are keyed by ID, so the newgen would wear the retired player’s face.

What I used instead were “vessels”: records in the player table that belong to no team and have no real-face flag. There are 7,226 of them, enough for decades of seasons. A newgen is written into a vessel and copies the whole appearance record of a donor of the same nationality. The game then builds a new face from those appearance parameters.

Testing turned up one more trap. A newgen whose position proficiencies were all zero counted as “can’t play this position”, and his overall showed as 43. Once the proficiencies were copied from the template player he was built from, the overall looked right.


Pitfall 4: saves tied to the database

One day I swapped in a different database file, started the game, and Arsenal were unrecognisable. Six players added later were gone, and 23 original veterans had come back. By then it was season 8, so they were all 31 to 39.

The cause: a save only recorded what it had changed on top of the database (transfers, growth, replacements), and it re-read the base from the game folder every time. Swap the file and the base changed underneath it.

It got worse. Another feature (re-rolling goal celebrations on every launch) wrote straight to the real database. It rewrote the file from 16 MB to 20.6 MB and overwrote the editor’s backup along the way. The version from before the swap was gone for good.

The fix: every save now carries its own copy of the database it was started with. The file is named by its content hash and never changes once written. The league reads it, and the in-game shadow is copied from it. I also removed every write to the real game files while I was at it: the program now only reads them.

Lesson: before storing diffs, pin down the baseline. A diff is relative to something. If that something can change, the diff means nothing.


Pitfall 5: you can’t tune numbers by playing

Injury rates, growth speed, market values, board confidence, Hall of Fame thresholds: every one of those numbers had a “tune after a few seasons” note on it. But playing one season by hand takes dozens of hours.

So I built a developer mode. It copies the save, manages your team for you, fast-forwards N seasons, and writes a health report at the end of each one. Is the world getting older? Is money inflating? Are there too many players rated 80+? Every number sits next to a reference value, and anything out of range is flagged.

That’s how I found the “68 young players after four seasons” problem. Real clubs don’t only lose players to retirement: people also move to other leagues or abroad, so “leaving” has to come in two kinds. The leaving probability for each age comes from the database’s own age distribution: about 8,000 players, subtracted year by year.

Lesson: let the system run and show you. Tune by feel and by the third system everything’s a mess.


Pitfall 6: fixed today, formations not taking effect

The day after the first version went out I got this feedback: really enjoying it, but formation and position changes made in the tactics screen don’t seem to take effect in the game.

It turned out the writes were completely fine. The squad table went in every match and the reload succeeded. What was wrong was which slot means which position, and two mistakes were stacked on top of each other:

  1. The formation was never written to the game. I had only written which 11 players start and never touched the game’s tactics board. You picked 3-5-2, and in the game Arsenal were still in their original 4-2-3-1.
  2. The slot order didn’t match. The game’s formation table lists defenders as GK, CB, CB, RB, LB…, while my presets used GK, RB, CB, CB, LB…. The game decides who plays where by slot, so even with the same formation, the player you put at right-back ended up at centre-back.

The fix: before kickoff, the formation you chose is written to the game’s tactics board, using the coordinates of the most common layout of that shape in the game itself. All 6 presets have a match among the game’s 1,916 formations. The starting XI is reordered into the game’s slot order, and pairs like the two centre-backs or two strikers are matched left-to-right as on the pitch diagram.

Lesson: “written” doesn’t mean “in effect.” The tests only proved I wrote what I meant to write, not that the game used it the way I assumed.


How I built it with AI

I built this project together with Claude Code. Getting through more than a hundred requirements in three weeks didn’t come from having the AI write code nonstop. It came from a few rules:

  • One design doc for every requirement. Each time I had an idea, it became an R-xx entry with my exact words, the goal, where the data comes from, the design, the acceptance criteria and anything still undecided. We read it before starting work and updated it afterwards. It’s now thousands of lines long, and it doubles as the project’s memory.
  • Tests pin down behaviour. Hundreds of tests, many of them running on a copy of the real database: write to the shadow, reopen it, check the change is there and the length is unchanged.
  • Verify in the game whenever possible. The AI can drive the game itself with key presses and screenshots: enter a friendly, read the overalls on the game plan screen, compare before and after. The reload in pitfall 1 and the newgens in pitfall 3 were both checked this way.
  • Write down what’s uncertain. Plenty of entries in the doc end with “not verified in game”. That’s nothing to be ashamed of. Pretending you verified it is.

Summary

#ProblemRoot causeLesson
1Game doesn’t reload dataReads once at startup; the old hook never workedCount how often it actually took effect
2Can’t write into the shadowIn-place writes can’t grow; the original compressor beats zlibFirst make sure you can write the original back
3Newgens without facesReal faces are keyed by IDUse free vessels, don’t take an identity
4Swapping the database wrecks savesSaves stored diffs against a moving baselinePin down the baseline first
5Numbers can’t be tunedPlaying a season by hand is too slowLet the system fast-forward and show you
6Formations don’t take effectFormation never written; different slot orderWritten ≠ in effect

Looking back, none of these six was an algorithm problem. Every one was “I assumed the game works like this”: that it would reload, that the data could be written back, that an ID was just an ID, that the base wouldn’t change, that the slot order was the same.

Bolting a system onto a game that wasn’t designed for it means spending most of your time correcting those assumptions.


by Jiahao Ren | github.com/Giggitycountless | jiahao.uk