Discrete Event Simulation
Why the engine advances time event-by-event, and the pure-scheduler model that drives a single iteration
The engine never ticks. It keeps a queue of future events sorted by time, pops the earliest one, jumps the clock straight to that event's timestamp, and asks a handler what to do. Nothing happens between events because, by construction, nothing can happen between events.
That single sentence is the whole mental model. Everything below earns the detail.
Why not a fixed time step
The naive way to simulate combat is a fixed-step loop: advance a millisecond, check what's due, advance another millisecond. It is simple and wrong in two directions at once. Most milliseconds have nothing due, so you burn cycles on empty ticks; and anything finer than your step (a tick that lands at 1.5 ms past a step boundary) gets quantised to the grid. A combat sim spends almost all of its wall-clock time idle between a cast finishing and the next global cooldown expiring. For a fixed-step loop that idle time is pure overhead.
Discrete event simulation inverts the loop. Instead of asking "what time is it now, and what is due?" it asks "what is the next thing that happens, and when?" Time advances in one jump to that event. A 300-second encounter that resolves in a few thousand events touches a few thousand timestamps, not 300,000 milliseconds. This is the standard event-driven formulation of simulation; none of it is novel here, and I lean on the simulation literature rather than reinventing it.
The trade-off is that you give up the implicit ordering a tick loop gives you for free. With discrete events you have to keep the event set sorted yourself, and you have to be deliberate about ties (two events at the same millisecond). The engine solves ordering with a timing wheel and breaks ties FIFO by insertion sequence.
The engine is a pure scheduler
The second design commitment is that the loop knows nothing about combat. It understands eight event variants and a SimState clock, and it dispatches to a SpecHandler trait object. The fields of SimEngine say it plainly: a queue, a clock, telemetry, an RNG, the handler, a sink, and the encounter end. Nothing about damage, auras, resources, or cooldowns:
All the combat logic lives behind that SpecHandler trait in engine-combat. The loop's only jobs are: pop the next event, advance the clock, call the matching handler method, and drain whatever the handler scheduled back into the queue.
This split is what lets the same loop drive any spec. The handler is the spec; the loop is the clock. It also keeps the loop trivially testable in isolation. A mock handler that schedules a few events exercises every branch without a single line of game data.
SimTime is the unit of that clock: a u32 of milliseconds, nothing more.
Not seconds, not floats. Integer milliseconds, so event timestamps are exact and the timing wheel can index them directly. The conversion to real seconds only happens when a rate like DPS or regen needs it, and it divides by a thousand right there at the read site.
One iteration, end to end
A single Monte-Carlo run is one combat from t = 0 to the encounter end. The host (CLI, browser worker, compute node, or the forge profiler) does not call the loop directly. It calls simulate_intent, which parses the sim config, resolves all game data, builds the handler, and only then drives the loop once per iteration.
This figure expands the Engine box of the system-context diagram (see Architecture). Reading it left-to-right and top-to-bottom:
simulate_intentis a thin convenience wrapper; it packs its arguments into aSimRequestwith default overrides and forwards tosimulate_intent_request.bootstrapis the heavy lifting: parse the TOML, requireintent_version == "v1", resolve the spec, find its descriptor in the inventory registry, turn gear into stats, and resolve every spell and aura the spec touches into aResolvedGameDatasnapshot. Game-data resolution is its own subject, covered under Data Resolution.- The descriptor's factory builds the handler from the resolved data and stats. This is where the generated spec code meets the runtime.
run_chunkthen runs the iteration loop, each pass invokingSimEngine::runagainst the sharedEventQueue, accumulating into oneTelemetryAccumulator, and finally encoding aChunkReportof protobuf telemetry bytes.
The unit of work here is the chunk: a ChunkAssignment declares some number of iterations, and the chunk runs them sequentially in one thread, reusing one handler, one queue, and one sink across all of them. The chunk is also the atomic unit of distribution. The orchestration and hosted-compute pages pick up the story from there.
The next page opens the SimEngine.run box: the eight event variants, the dispatch loop, the timing wheel, and the deterministic RNG that makes a run reproducible from its seed.
Next steps
