DEV Community

Cover image for Foxes And Rabbits Made Me Question My Own Engine
Matteo Antony Mistretta
Matteo Antony Mistretta

Posted on

Foxes And Rabbits Made Me Question My Own Engine

I'm the author of Inglorious Forge — a monorepo containing Inglorious Engine, Store, Web, UI, and a few other packages built around one core idea: composable, data-oriented state instead of classes. This is the story of the weekend I found the edge of that idea.

The hubris

Many years ago, in a university OOP course, I met Foxes and Rabbits — the classic Objects First with Java simulation used to teach polymorphism and encapsulation. Fox and Rabbit extend Animal, each overrides act(), a Field grid holds them all.

I never forgot it, and with an AI assistant doing a lot of the typing, porting it to Inglorious Engine felt like a fun weekend project — one of those where you start Friday evening on a whim and either have something working by Sunday night or a very good story. I got both, and in that one weekend, not the drawn-out slog it might sound like in retrospect.

In Inglorious Engine, an entity is a plain object with a type, and the type itself is just a plain object of event handlers — update(entity, dt, api), render(entity, ctx) — not unlike a class with methods, minus the class. Most of the engine's docs show exactly that: a Player type, a handful of handlers, done. For Foxes and Rabbits, though, I wanted Fox and Rabbit to share a pile of behavior — aging, moving on a grid, breeding, dying — without copy-pasting it into two objects. So I reached for a different part of the composition system I'd designed for exactly this: a handler can be written as a function that wraps an existing type instead of a plain object:

export function aged() {
  return (type) => ({
    update(entity, dt, api) {
      type.update?.(entity, dt, api) // call whatever came before
      entity.age += 1
      if (entity.age > SPECIES[entity.type].maxAge) {
        entity.isDying = true
      }
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Stack a handful of these — Fox: [dies(), aged(), movesOnGrid(), breeds(), eats()] — and you get the GoF Decorator pattern, for free, just expressed as function composition instead of interface inheritance. It's arguably the sharpest tool the engine has, and this simulation turned out to be the perfect showcase for it: five small, independently testable slices of behavior instead of one bloated Fox.act().

The store underneath uses Mutative for structural sharing: mutate a draft, get back a new immutable state, and — crucially — only the entities you actually touched get copied; everything else keeps its old reference. I had documentation bragging about this. In an old demo comparing entity pooling with and without it — bubbles that spawn, drift across the screen, and despawn at the edges — switching the engine from Immer to Mutative made pooling unnecessary; I'd written: "FPS fluctuations have largely disappeared — we now recommend sticking with standard immutable entities unless you specifically encounter performance bottlenecks." That demo's entities do move every frame, not just spawn and despawn, but moving means overwriting one position vector — a small, cheap write compared to what was coming. Still not stale advice, either — it's still true for that workload. It just turned out to describe one shape of problem, not all of them.

Foxes and Rabbits, I assumed, would be a nice showcase of that same strength. A 120×80 grid, close to ten thousand animals at capacity, decorators doing the work classes used to do. What could go wrong.

Nose, meet wall

First it was correctness: rabbit population exploding unbounded, because nothing capped growth. Fixed with a grid occupancy system — a plain array tracking who's where, updated incrementally. Population capped at ~9,600 as intended.

The game still crawled. I'd configured the simulation with a fixed update rate of 10 ticks per second — a deliberate choice, decoupled from rendering — and even that modest rate started dropping, sinking toward one tick per second, then freezing for whole seconds at a time. Capping the population hadn't fixed the real problem, because the real problem was never the population size.

What followed was a genuinely humbling parade of plausible, wrong theories. I suspected a quadratic scan hiding in the predator-search logic — nope, grid lookups were already O(1). I suspected the Redux DevTools browser extension silently accumulating action history — real, measurable, but not the dominant cost; closing the panel barely moved the needle. I suspected a leftover full-grid rebuild running every tick — turned out it only ran once, at startup; I'd misread my own code.

Then I actually opened the Chrome DevTools Performance tab and stopped guessing. The frame timeline showed clean 8ms frames interrupted by 200ms+ freezes, precisely periodic — the signature of something accumulating and bursting all at once. The Bottom-up view named names: get, set, ownKeys, getOwnPropertyDescriptor — over 25% of self time. Those aren't application function names. Those are Proxy trap names.

The Bottom-Up view of the Flame Graph chart shows the real culprit

Mutative wraps every object you touch in a draft Proxy, to track exactly what changed. That's brilliant when a handful of entities change out of thousands. In Foxes and Rabbits, at any given tick, close to 100% of ~9,600 animals mutate age, energy, and position — several properties each, not one. There's nothing to not copy. The tracking machinery was pure overhead with zero payoff — the worst possible workload for the exact thing I was proud of.

I ran the experiment properly. Bypass the immutable step entirely, mutate in place: instant. Deliberately silly control, just to calibrate: state = JSON.parse(JSON.stringify(state)); patch(state) — a full serialize-and-reparse round trip, structurally about as dumb as cloning gets — still faster than the "optimized" incremental tracker. Swap in the native structuredClone() instead: same speed, no string-encoding overhead — and no correctness tax to weigh against it, either, since Inglorious Store, like Redux, has required plain serializable state since day one. Every entity here was already just data; cloning it was never the risky part, only ever a performance question.

The pattern was unambiguous: the cost was never "produce a new state object," it was "track exactly what changed across thousands of live proxies" — and when almost everything changes, that bookkeeping is a bill with no discount attached.

For one uncomfortable evening, I sat with the thought every functional-programming advocate dreads: if just cloning everything and mutating in place wins, doesn't that mean the mutable-OOP crowd was right all along?

The acceptable solution

No — but only because I'd been asking the wrong question. Not "immutable vs. mutable." Three strategies, not two: in-place mutation, incremental proxy-tracked immutability, and full-clone immutability. My engine had only ever offered the second. The fix wasn't to abandon immutability — it was to offer both, as an explicit choice:

createStore({
  updateStrategy: "structural-sharing", // default — cost proportional to what changes
  // updateStrategy: "full-clone",      // cost proportional to total state size
})
Enter fullscreen mode Exit fullscreen mode

Existing projects are unaffected. Nobody has to restructure how they model entities to opt in. The decision rule is one sentence: if most entities are idle most frames, keep the default; if most entities change most properties every frame, switch strategies. A platformer with a player and a handful of enemies among ten thousand static tiles: default, easily. A dense grid simulation where nearly everything moves, ages, and breeds every tick: full-clone.

The justification, earned rather than assumed

Here's what turns this from "I hit a wall and patched around it" into something I'd actually stand behind: this isn't a workaround unique to my engine's limitations. It's the same choice serious systems make on purpose, for the same reason.

Cellular automata and GPGPU compute shaders don't diff their buffers — Game of Life implementations rewrite the entire grid every generation by construction, because when every cell potentially changes, there's nothing worth tracking incrementally. Rollback netcode in fighting games (GGPO and its descendants) saves a complete game-state snapshot every single frame, specifically so it can be restored and re-simulated when a delayed input arrives — full clones, at 60fps, in code written by people who'd have abandoned the idea decades ago if it were actually indefensible.

Structural sharing isn't wrong. It's correct for the shape of problem it was built for: sparse change on a large, mostly-static tree — most UI state, most traditional games, most of what Inglorious Engine will ever be used for. Full-clone is correct for the opposite shape: dense change where almost nothing is worth sharing. Neither one is "the mature choice" and the other "the naive one" — they're two answers to two different questions, and the mistake was never picking the wrong one. It was assuming, in the documentation, that one answer covered both questions.

Foxes and Rabbits taught me that lesson using a simulation designed to teach a completely different one. I'll take it.

The hubris, vindicated

There's one more comparison worth making, and it's the one that actually justifies the hubris I started with.

Had I hit this same wall in the original Java version — decide, mid-project, that Field needed a completely different state-update strategy — I'd have been restructuring Animal, Fox, Rabbit, and Simulator together. State strategy and domain logic live in the same objects in that design; you can't change one without touching the other.

Here, the fix was a one-line config change on the store. Fox, Rabbit, aged, breeds, movesOnGrid, eats, dies — every line of game logic I'd written that weekend — didn't change at all. That's the actual payoff of keeping state transitions outside the entities that own the data: swapping how the store produces its next state is infrastructure, not domain logic, and infrastructure is supposed to be replaceable without anyone upstream noticing. The hubris was assuming one strategy would always be enough. It just turned out the architecture that caused the problem was also the one cheap enough to fix.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.