Building Wakfuli: Two Years of Decisions
Wakfuli, seen from the outside, is a web app where you build and share character builds for an MMO. Seen from the inside, it's a data pipeline that happens to have an interface. Almost every decision that mattered over two years came from there, not from the front end.
Two years, in terms of rhythm, look like this: when and how much I committed to wakfuli-builder, the product's main repository.
I'd been putting off this write-up for months, always for the same reason: I couldn't decide what it was actually about. I eventually had to admit it. What I built isn't the screen you see, it's the machinery that feeds it correct numbers.
The constraint that shaped everything
The starting idea fit in one sentence: help a player optimize their build, with correct numbers. The key word is correct. An optimizer that shows approximate values is useless, it misleads people with the authority of a clean interface.
The problem is that these numbers don't belong to me. They live inside a game I don't control, shipped as opaque binary files, and that changes on its own schedule. With every patch, effects get added, values shift, mechanics appear. My only certainty was that the data would always be moving and never cleanly provided.
That constraint shaped everything else. It's why I spent far more time on the chain that produces the data than on the screens that display it.
Note
A product is never better than the data it shows. For a calculation tool, the interface is the easy part, correct and up-to-date data is the real product.
Isolating what moves from what must stay stable
The foundation is a monorepo: a Next.js front end, an AdonisJS back end on Postgres and Redis. Nothing exotic. The one architectural decision I'll really stand behind is structuring the back end as hexagonal, with each business domain split into domain / application / adapters / services.
The intuition came directly from the constraint. Since game data was going to keep changing, I wanted that instability confined to the edges, in the adapters, without bubbling up to contaminate the business logic. A port declares what a service needs, an adapter wires the ORM behind it. When a format changes, I touch the adapter, not the domain.
// application/ports.ts: the domain declares what it needs, without knowing where it comes from
export interface SkinRepository {
findById(id: number): Promise<Skin | null>
search(filter: SkinFilter): Promise<Skin[]>
}I'm not going to pretend it's perfect everywhere.
Note
Hexagonal architecture can turn into folder theater. In Wakfuli, some domain entities are still ORM models, not pure objects: the boundary protects access to the data, not the entity itself. And a service sometimes types against the adapter's concrete class rather than the interface, because of the injection container. I live with it, but it's a real gap between theory and code.
The sign that the split isn't dogmatic: the module that exposes read-only reference data has neither a port nor an adapter.1 The discipline applies wherever there's a boundary worth defending, not everywhere on principle.
- There's nothing to isolate there, no business rule to protect, so I didn't force the mold. ↩
Treating effects as data, not code
The business core of Wakfuli is the effects system. A spell or an item isn't "deals X damage". It's a small list of effects applied in order, each one able to read the state left by the previous one. The meaning of each effect is described by a template language that the game never documented and that had to be reverse-engineered by observation.
The temptation at the start is to hardcode each case by hand. That works for the first ten. By the thirtieth, with conditions, thresholds, and cross-references, it becomes unmanageable. The right abstraction is to treat the effect as data, a tree that a generic engine resolves, rather than as specific code.
And that's where the project's most counterintuitive decision landed. My initial instinct was to prune at build time: discard effects marked hidden or statically unreachable, to keep only a clean tree. Bad idea.
Clean tree, but broken tooltips in production: a parent title sometimes references a parameter carried by an effect I had discarded. The text lost its value, or showed a raw, unresolved calculation.
Everything kept in the dump, filtering happens at display time, when the context is finally known. Heavier to carry around, but correct.
Where you decide to discard information matters as much as the decision itself. Discarding early is fast and wrong. Discarding at the last moment is a bit more costly and right.
The data: where everything gets complicated
If you only read one section, make it this one. For Wakfuli, it's the equivalent of what networking is for an online game: the place where pretty theories meet reality. Here, reality is a game that updates whenever it wants and a production database that has to keep up without breaking.
The principle that ended up holding everything together is that a version number propagates in a single direction through the whole chain. A patch produces new versioned artifacts, the next stage consumes them and publishes its own version, and so on down to the site's database. At no point does a downstream stage guess at data that an upstream stage should already have provided, resolved.
The trickiest moment is reloading the production database. "Reload data into a production database" is exactly the kind of phrase that, executed badly, ends with an empty database on a Saturday night. The safeguard was to never restore directly into production.
- 1
Back up
The current state, before touching anything.
- 2
Restore to staging
First into a disposable database, never straight into production.
- 3
Validate
Count the rows in staging, and bail out if anything is empty.
- 4
Switch over, then verify
Only then restore into production, and run one last count check.
// Guard rail: bail out before touching prod if staging is empty
const stagingEffects = await countRows(staging, 'effects')
if (stagingEffects === 0) {
throw new Error('Staging vide, restauration prod annulée. Backup intact.')
}There's also a lesson borrowed word for word from networked games.
Never trust the source
The dump extracted from the game contains internal artifacts, test or admin objects that were never meant for the public. The first version displayed everything. The right architecture treats extracted data as untrusted: a public tier filtered by an allowlist, a full internal tier, and by default a new item stays excluded from the public tier until it's been validated.
Optimizing: measure before guessing
The most expensive sentence in the project is "I think it's slow because of X." Every single time I said it, I was wrong. The real culprit was never the one I suspected.
My favorite example is color-based search. The appearance filter lets you sort a catalog by hue proximity. The naive version compared hex codes in application memory, across thousands of items, on every request. Slow, and perceptually wrong, because two hex codes that are numerically close can look very different to the eye.
The right answer moved the work to the right place. At ingestion time, I precompute each color's perceptual value, the Laba color space where Euclidean distance corresponds to a perceived visual difference, unlike RGB space, stored in an indexed column. Proximity then becomes a geometric distance that the database resolves with a spatial index, GiSTGeneralized Search Tree, Postgres's index for multidimensional data, instead of an O(n) sort on the application side.
-- The database does the geometry, not the application
SELECT id
FROM items
ORDER BY color_lab <-> cube(ARRAY[$1, $2, $3])
LIMIT 20;Note
None of my real optimizations were clever. Precompute at ingestion, index the right thing, stop recalculating the whole catalog when a single row changes. Obvious, once you have the profile in front of you. The skill wasn't optimizing, it was measuring first.
Mistakes I wouldn't make again
The first, treating data as a late-stage layer. I started with the screens, telling myself I'd wire up the real data later. That was the core design mistake. The pipeline isn't plumbing you lay under a finished product, it's the constraint that should have informed every decision from day one. Exactly like networking in an online game.
The second, trying to generalize too early. I built abstractions for cases that never happened, and they slowed me down on the ones that did. Applying hexagonal architecture everywhere at the start, before knowing where the real boundaries were, is part of that.
The third, neglecting tooling. Tracking formats across game versions, and cleanly renaming fields that shift with every patch, I did by hand for a long time. The tool that automates that came too late. Built earlier, it would have saved me weeks on every update.
What I take away from it
Wakfuli isn't perfect, and I'm fine with that. What the project taught me lies less in the finished code than in a handful of principles I eventually stopped negotiating on.
Points clés
- Isolate what moves from what must stay stable.
- Treat data as data, never as code.
- Decide where to discard information with as much care as everything else.
- Never trust a source you don't control.
- Measure before optimizing, always.
The common thread, if there is one, is that I spent two years believing I was building a web app, when I was actually building a data system that had to stay correct while an unstable source kept shifting under my feet. The day I accepted that, most of the decisions started to fall into place naturally.