DEV Community

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

Posted on Edited 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 — and of the week after, when I found out the edge was further in than I thought.

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

(That second option has since been reworked and renamed — more on that in a moment.)

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 wall behind the wall

I'd have stopped the story there, if I hadn't kept going.

A simulation you can't poke at is only half a demo, so I added controls: sliders for every breeding and energy parameter, a pause button, a reset button. At 10 updates per second the controls felt awful — a slider that only moves ten times a second is a slider that lags behind your hand. So I did what seemed obviously right: let the loop run on requestAnimationFrame, as fast as the screen allows, and give each animal its own 10fps clock with a small atFps(10) decorator, so the world still lived at its own pace. Then, to prove the shell was generic, I dropped Conway's Game of Life into the same game, with the same controls and the same full-clone store.

Game of Life ran beautifully. Foxes and Rabbits sank to about five frames per second.

My first theory was that full-clone just wasn't enough for "that many moving parts." The second was that it shouldn't even be running: the animals only act ten times a second, so surely there were no events, and no clone, in the frames between. Both were wrong, and measuring — headless, in Node, 600 frames, no rendering — showed why:

  • Game of Life never had more than ~100 entities. It wasn't proving anything. Foxes and Rabbits fills the board to ~9,000.
  • The engine queues an update event on every animation frame. So the store's "nothing to do" shortcut never fired, and full-clone structuredCloned all 9,000 animals 60 times a second, even on the 50 frames where none of them acted. That clone alone took ~11ms — two thirds of a 16.7ms frame, before a single rule ran or a single rabbit was drawn.
  • atFps made every entity change every frame. To keep its own time, each animal wrote a clock field on every frame — so even a smarter copying strategy would have found nothing unchanged.

The full clone wasn't paying for the turns. It was paying for the pointer.

Fix one: copy what you hand out, not the world

The rule I wrote down in the first half — cost proportional to total state size — turned out to be the actual problem. So the strategy changed: instead of cloning the whole state up front, the store now copies the entity map (cheap: a reference per entity) and deep-copies each entity the first time an event is dispatched to it. Handlers still get a plain object with no proxy, so writing to it costs nothing however often it happens. Entities nothing touched aren't copied at all, and keep their old reference.

It got a name that says what it does:

createStore({
  updateStrategy: "copy-on-dispatch", // was "full-clone"
})
Enter fullscreen mode Exit fullscreen mode

Systems are the one subtlety. A system receives every entity at once, so each entity it reads through the draft is copied in case it's written to. A system that only needs to look — say, a readout counting foxes and rabbits — reads from api.getEntities() instead and only writes through the draft.

Along the way I replaced structuredClone with a hand-written deep copy for plain data, about twice as fast across thousands of small objects. It also fixed a bug I didn't know I had: my vectors are arrays tagged with a non-enumerable __isVector__ flag, and structuredClone silently drops it. Every cloned position had quietly stopped being a vector — and a helper that tells a vector from an entity by that flag would have gone looking for position.position. The new copy keeps vectors as vectors.

Fix two: one clock for the world

The other half was giving the world one clock instead of nine thousand. Conway's Game of Life already did this — it's a system, not a pile of entities, and it kept its own time. So I pulled that out into the engine as a ticks system: it keeps a single clock, and whenever a turn is due it notifies a tick event carrying the time the turn covers. Whatever lives at the world's pace handles tick instead of update:

import { ticks } from "@inglorious/engine/systems/ticks.js"

export function aged() {
  return (type) => ({
    tick(entity, elapsed, api) {
      type.tick?.(entity, elapsed, api)
      // ...same body as before
    },
  })
}

const game = {
  loop: { type: "animationFrame" }, // the pointer and sliders run at 60fps
  systems: [ticks({ fps: 10 })],    // the world turns at 10
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The loop still runs every animation frame, so the controls are as snappy as before. But between turns, the only entities that change are the handful of UI ones — and with copy-on-dispatch, those are the only ones copied.

The numbers

Same seeded world, same final population (5,589 animals — the simulation behaves identically), headless, no rendering:

Average frame Median Worst
Full clone, per-entity clocks 17.8 ms 16.3 ms 74.9 ms
Copy-on-dispatch 11.8 ms 10.2 ms 36.1 ms
Copy-on-dispatch + world clock 6.3 ms 3.9 ms 31.7 ms

Frames between turns now cost ~3.6ms. Turns themselves cost ~20ms with copy-on-dispatch — against ~35ms under structural sharing, so the original lesson still holds: when thousands of entities change at once, plain objects beat proxies. Rendering is still the open question, and that one belongs to the browser's Performance panel, not to a headless benchmark.

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. I checked that too, rather than assume it again. In a store with one entity holding 10,000 table rows, toggling a single row takes 0.027ms under structural sharing and ~2ms under copy-on-dispatch — seventy times slower, because copy-on-dispatch copies the whole entity an event reaches, while structural sharing copies only the path that changed. And React bindings and memoized selectors rely on untouched entities keeping their reference, which structural sharing guarantees. In small games like Pong or Breakout, both strategies cost microseconds a frame; structural sharing also freezes state in dev mode, which catches bugs.

So the decision rule got a little longer:

Situation Strategy
Web apps, React apps structural-sharing (default)
Small games either — structural-sharing for the dev-mode freeze
Thousands of entities changing every turn copy-on-dispatch
A world that turns slower than the screen a single ticks clock, not a clock per entity

Neither strategy 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. The second time around, the mistake was assuming I knew which cost I was paying without measuring it.

Foxes and Rabbits taught me that lesson using a simulation designed to teach a completely different one. Twice. 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, both store changes — first full-clone, then copy-on-dispatch — were a one-line config change. Fox, Rabbit, aged, breeds, movesOnGrid, eats, dies didn't notice. Moving to a world clock touched them, honestly, but only by renaming the handler each decorator wraps from update to tick and deleting atFps from the stack; not one line of the rules inside changed. That's the actual payoff of keeping state transitions outside the entities that own the data: swapping how the store produces its next state — or how often the world is asked to act — 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 — twice.

Top comments (1)

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