Simulation Building Blocks
A deterministic game simulation tends to grow the same shapes over and over: a long Step() whose order is only visible by reading it, if / else if ladders that pick a movement mode or a hit multiplier, gravity scales patched by five conditions, a bot's "score every role, add noise, keep the current one a bit longer" block, overlap masks, a dozen cooldown fields, button edges, a list sorted after every add, an event list threaded through every function, and contact listeners that test tag types in both orders and flip the normal by hand.
Every one of those is a missing abstraction. Altruist ships them as small, generic building blocks: the game declares what the rules, steps and modifiers are, and the block decides how they run, always in a fixed, documented order. Each block has a TypeScript twin with the same semantics, so a client that predicts the server's simulation can be built from the same pieces.
- C#: namespace
Altruist.Gaming.Flow(packageAltruist.Gaming);ContactRouter2Dand the contact queries are inAltruist.Physx.TwoD(packageAltruist.Physx). The state machine gainsAdvance/RunState. - TypeScript:
@altruist/sim2d/flow(built on the math layer only) and@altruist/sim2d/physicsfor the contact router. See TypeScript Client Package for the setup.
Guarantees
Every block follows the same rules, which is what makes it safe in an authoritative simulation and in a client that must replay it:
| Guarantee | What it means |
|---|---|
| Fixed order | Rules, steps, modifiers and options run in the order they were built. Nothing is sorted, cached or reordered at run time. |
| Short-circuit like the inline code | A selector evaluates predicates up to the first match, each exactly once; later rules never run. Operand functions run only when their entry applies. So side effects (an RNG draw, a latched flag) happen exactly as in the if ladder they replace. |
| Float exactness | Each numeric step is one float32 operation applied as written (value * x, MathF.Min(value, x), MathF.Max(0, t - dt)), with no reassociation. The blocks are tested bit-for-bit against the inline code. |
| Fixed RNG consumption | The utility selector draws noise for a fixed set of options in a fixed order, whatever the situation, so replays stay aligned. |
| No allocation per tick | Everything is built once into arrays of structs; evaluating does not allocate (C#: verified with GC.GetAllocatedBytesForCurrentThread). Keep your delegates free of per-call captures to keep it that way. |
| Editable at setup | Every list is named. Before Build(), a mode, a mutator or a test can insert, replace or remove one entry by name. After Build() the object is immutable. |
Editing by Name
All builders share one editing model (OrderedBuilder): adds append, unless a one-shot position is set just before the add.
var b = FirstMatch<Car, float>.Create()
.When("super", c => c.SuperStrike, c => c.Config.SuperMultiplier)
.When("dash", c => c.DashStrike, c => c.Zone * c.Config.DashMultiplier)
.Otherwise(c => c.Zone);
b.Before("dash").When("perfect", c => c.Perfect, c => c.Zone * c.Config.PerfectMultiplier);
b.Replacing("super").When("overcharged", c => c.Overcharged, 4f);
b.Remove("dash");
var rules = b.Build(); // overcharged, perfect, otherwise
Names are unique; a duplicate, an unknown anchor, or a position with no entry after it throws.
FirstMatch: Rule Selectors
FirstMatch<TCtx, TResult> picks a result; FirstMatch<TCtx> (TypeScript: FirstMatchActions) runs an action. The first matching rule decides.
// The hit multiplier ladder.
var multiplier = FirstMatch<HitContext, float>.Create()
.When("super", c => c.SuperStrike, c => c.Config.SuperStrikeMultiplier)
.When("kick", c => c.Kick, c => MathF.Max(c.ZoneMult, c.Config.Nose) * c.Config.FlipKick)
.When("perfect", c => c.PerfectStrike, c => c.ZoneMult * c.Config.Perfect)
.When("dash", c => c.DashStrike, c => c.ZoneMult * c.Config.Dash)
.Otherwise(c => c.ZoneMult)
.Build();
float mult = multiplier.Evaluate(ctx);
A Try rule looks closer and may decline, so the next rule is tried. This is the shape of a tactic that latches a flag and rolls a chance before committing:
var strike = FirstMatch<Bot, Input>.Create()
.Try("reset-chain", (Bot b, out Input input) =>
{
input = default;
if (b.FlipPlanned || !b.BallAboveAhead) return false;
b.FlipPlanned = true; // latched even when the roll fails
if (!b.Rng.Chance(b.Profile.ComboRate)) return false;
input = b.StartCombo("reset-chain");
return true;
})
.When("aerial", b => b.BallHigh, b => b.Aerial())
.When("get-behind", b => !b.Behind && b.Close, b => b.BackOff())
.Otherwise(b => b.Drive())
.Build();
// Movement modes: the first branch that applies acts.
var movement = FirstMatch<Car>.Create()
.When("jump", c => c.JumpPressed && c.CanJump, Jump)
.When("flip", c => c.JumpPressed && c.AirJumpReady && c.FlipDir != 0, Flip)
.When("air-jump", c => c.JumpPressed && c.AirJumpReady, AirJump)
.When("drive", c => c.Grounded, Drive)
.When("glide", c => c.EdgeFlight > 0, Glide)
.Otherwise(AirControl)
.Build();
movement.Run(car);
import { FirstMatch, NO_MATCH } from '@altruist/sim2d/flow';
const multiplier = FirstMatch.create<HitContext, number>()
.whenValue('super', (c) => c.superStrike, cfg.superStrikeMultiplier)
.when('kick', (c) => c.kick, (c) => Math.max(c.zoneMult, cfg.nose) * cfg.flipKick)
.try('combo', (c) => (c.rng.chance(c.comboRate) ? c.startCombo() : NO_MATCH))
.otherwise((c) => c.zoneMult)
.build();
TryEvaluate (TypeScript: tryEvaluate returning NO_MATCH) reports "nothing matched" when there is no default; Evaluate throws then. The matching rule's index is returned (C# out ruleIndex, TypeScript lastRule), with NameOf for logs and counters.
TickPipeline: The Phases of a Tick
TickPipeline<TCtx> is the ordered, named list of what one tick does. Steps are plain actions, stop points (StopWhen: true ends the tick early, e.g. while the world is frozen), or per-entity blocks (ForEach) that run all their sub-steps for one entity before the next. Inside a block, SkipWhen is the continue of a foreach.
var perCar = new EntitySteps<Match, Car>()
.SkipWhen("demolished", (m, c) => m.TickDemolished(c)) // true: rest skipped for this car
.Step("input", (m, c) => m.ReadInput(c))
.SkipWhen("frozen", (m, _) => m.Frozen)
.Step("movement", (m, c) => m.Move(c))
.Step("dash", (m, c) => m.Dash(c));
var tick = TickPipeline<Match>.Create()
.Step("clock", m => m.Phases.Advance(m, m.Dt))
.ForEach("cars", m => m.Cars, perCar)
.Step("dash-gap", m => m.CaptureDashGaps())
.StopWhen("frozen", m => m.Frozen && m.RunFrozenPhase())
.Step("physics", m => m.World.Step(m.Dt))
.Step("ground", m => m.UpdateGround())
.Step("ball-hits", m => m.ResolveBallHits())
.Step("demolitions", m => m.Demolitions())
.Step("timers", m => m.TickTimers())
.Step("phase", m => m.Phases.RunState(m, m.Dt))
.Build();
tick.Run(match);
Entities are iterated by index over an IReadOnlyList<T> (use an EntityRegistry for id order). The EntitySteps passed to ForEach is read when the pipeline is built, so it can still be edited until then. Run returns false when a stop point ended the tick (stoppedAt tells which).
ModifierStack: Float Modifiers in Order
ModifierStack<TCtx> applies Set, Add, Mul, Min, Max and Map entries to a value, in order. when gates an entry; Else() makes the next entry the else branch of the previous one (it is considered only when nothing in its chain applied).
var gravity = ModifierStack<Car>.Create()
.Set("dash", c => c.Config.DashGravity, when: c => c.Dashing)
.Else().Set("stall", c => c.Config.StallGravity, when: c => c.Stalling)
.Min("afterglow", c => c.AfterglowGravity, when: c => c.InAfterglow)
.Mul("wall", c => c.WallGravityFactor, when: c => c.OnSteepSurface)
.Mul("grip", c => c.GripFactor, when: c => c.OnSteepSurface)
.Build();
car.GravityScale = gravity.Apply(1f, car);
Each entry is exactly one operation, applied as written: value + x, value * x, MathF.Min(value, x), MathF.Max(value, x) (TypeScript: Math.min(value, x)). Keep state updates (a stall timer that runs while the stall applies) as plain code before Apply, and let the stack compute the value.
UtilitySelector: Utility Choice with Noise and Hysteresis
UtilitySelector<TOption, TCtx> chooses between a fixed set of options (roles, tactics, targets). One Decide runs these stages, in this order:
- Forced rules, in order: the first true decides at once (no scoring, no RNG draws).
- Prepare (optional): computes what the scorers share.
- Scores, in option order; a veto (which sees the raw score) sets the score to
-∞. - Noise, in option order, for every option marked noisy, vetoed ones included:
score += draw(rng) * scale. The default draw isDeterministicRandom.Gaussian(). - Inertia: the current option gets
heldBonuswhile(tick - since) * tickSeconds < hold,bonusafter. Transient options get none. - Best: start from the current option (the fallback while the current one is transient), walk the options in order, take any strictly greater score.
- Overrides, in order: each may replace the best and sees the scores.
var roles = UtilitySelector<Role, Bot>.Create()
.Force("kickoff", b => b.KickoffPending, Role.Kickoff)
.Prepare(b => b.ReadField()) // threat, intercepts, first man...
.Option(Role.Kickoff, -9f, noisy: false, transient: true)
.Option(Role.Attack, b => b.AttackUtility())
.Option(Role.Defend, b => b.DefendUtility(), veto: (b, _) => !b.Threat)
.Option(Role.Shadow, b => b.ShadowUtility())
.Option(Role.Retreat, b => b.RetreatUtility(), veto: (b, _) => !b.WrongSide)
.Option(Role.Support, b => b.FirstMan ? 0 : 1.05f, veto: (b, _) => b.FirstMan)
.Option(Role.Refill, b => b.RefillUtility(), veto: (_, score) => score == 0)
.Noise(b => b.Profile.DecisionNoise)
.Inertia(b => b.Profile.RoleHold, heldBonus: 0.6f, bonus: 0.3f, tickSeconds: dt)
.FallbackTo(Role.Attack)
.StartWith(Role.Kickoff)
.Override("shot-coming", (b, best, u) =>
b.Threat && b.ThreatTime < 1.2f && best != Role.Defend && u[Role.Defend] > 1.5f ? Role.Defend : best)
.Override("passivity-guard", (b, best, _) => b.CampingTooLong(best) ? (b.FirstMan ? Role.Attack : Role.Support) : best)
.Build();
var best = roles.Decide(bot, bot.Rng, tick);
if (roles.Choose(best, tick)) bot.OnRoleChanged(best); // Choose: false when unchanged
Decide does not change the current option, so the game runs its own "role changed" logic and calls Choose (or Select, which does both). The scores of the last decision are readable (Scores[option]) for debug overlays; LastForced names the forced rule that decided, if any. Restore(current, previous, since) rewinds the bookkeeping for rollback.
In TypeScript the options are usually string literals and the RNG is any object with gaussian() (a DeterministicRandom from @altruist/sim2d/math):
const roles = UtilitySelector.create<Role, Bot, DeterministicRandom>()
.force('kickoff', (b) => b.kickoffPending, 'kickoff')
.option('kickoff', -9, { noisy: false, transient: true })
.option('defend', (b) => b.defendUtility(), { veto: (b) => !b.threat })
.noise((b) => b.profile.decisionNoise)
.inertia((b) => b.profile.roleHold, 0.6, 0.3, dt)
.fallbackTo('attack')
.build();
OverlapLatch: Entered This Step
Overlaps that should act once, when they start (a ram, a pickup, a trigger), need last step's overlaps. OverlapLatch32 keeps them as an int bit mask over small ids (1 << (id & 31)); OverlapLatch<TId> does the same for any id type with sets.
car.Ram.Begin(); // current overlaps become the previous ones
Car? hit = null;
foreach (var other in opponents)
{
if (!Overlaps(car, other)) continue;
// Record EVERY overlap, even after a hit, so next step knows they were already there.
if (!car.Ram.Record(other.Id) || hit is not null) continue; // false: not fresh
if (Qualifies(car, other)) hit = other;
}
Only Current needs to be stored with the entity; a step that does not run keeps the mask, so the next one compares against the last one that ran.
IdMask32 names the mask operations (Bit, Has, With, Without) and captures a set at an event:
// At dash start: the opponents far enough away to count.
car.DashGapMask = IdMask32.Collect(cars, car, static (a, b) => b.Team != a.Team && FarEnough(a, b), static b => b.Id);
// Later:
if (IdMask32.Has(car.DashGapMask, victim.Id)) { ... }
Countdown and TimerSet
Countdown names the timer expressions kept in plain fields: Tick(ref t, dt) is t = MathF.Max(0, t - dt) and returns true on the tick it ran out; TickRunning only touches a running timer (if (t > 0) { ... }); CountUp(ref t, dt, cap) is t = MathF.Min(t + dt, cap).
Countdown.CountUp(ref car.DashTime, dt, Car.Never);
Countdown.Tick(ref car.DashCooldown, dt);
if (Countdown.TickRunning(ref car.FlipTime, dt)) car.FinishFlip();
if (!car.Grounded) Countdown.Tick(ref car.Coyote, dt);
TypeScript has no ref, so the field forms take the object and the key: Countdown.tickField(car, 'flipTime', dt); the value forms return the new value (car.cooldown = Countdown.tick(car.cooldown, dt)).
TimerSet is an indexed (optionally named) set ticked in index order, with expiry edges: Start(i, seconds), Tick(dt), IsRunning(i), Expired(i), Remaining for snapshots.
Input: ButtonEdges, InputLatch, Stick
var input = car.Latch.Resolve(inputs, car.Id); // the arrived input, or a repeat of the last one
var pressed = car.Buttons.Update(input.Buttons); // buttons & ~previous
if (car.Buttons.WasPressed(Buttons.Jump)) ...
bool steering = Stick.AxisBeyond(input.MoveX, deadzone); // MathF.Abs(x) > deadzone
bool aiming = Stick.Beyond(input.MoveX, input.MoveY, aimDeadzone);
InputLatch<T> is input = arrived ? input : last; last = input (TypeScript: resolve(input | undefined) / resolveFrom(map, key)). ButtonEdges keeps Held (store it with the entity) and reports Pressed / Released of the last update.
EntityRegistry: Id-Ordered Entities
EntityRegistry<T> (and EntityRegistry<TKey, T>) iterates in ascending id order, with O(1) lookup (TryGet, Find) and O(log n) position (IndexOf). It replaces "a list sorted after every add plus a linear search": the order is the same, iteration does not allocate, and adding or removing while iterating throws. TypeScript: items is the ordered array.
EventSink: Injected Event Collection
Pass one IEventSink<TEvent> instead of threading a List<TEvent> through every function. EventSink<TEvent> collects in emission order (clear it each tick, or DrainTo a list), and NullEventSink<TEvent>.Instance drops everything for scratch simulations (prediction, a bot's lookahead). Add is an alias of Emit (TypeScript: push of emit), so code written against a list compiles unchanged after the parameter type changes.
ContactRouter2D: Contacts by Tag Type
A hand-written contact listener tests the tags of both fixtures in both orders and flips the normal when the pair comes reversed. ContactRouter2D (an IPhysxContactListener2D) dispatches by the types of the two tags (a fixture's user data, else its body's) and orients the pair: A is always the first type, and the normal points from A's side to B's side.
var router = new ContactRouter2D()
// Pre-solve filters: false disables the contact for this step (one-way platforms, pass-through).
.FilterPreSolve((in RoutedContact2D<SurfaceTag, BallTag> c) => !BallPassesThrough(c.A, c.BodyA, c.BodyB))
.FilterPreSolve((in RoutedContact2D<SurfaceTag, VehicleTag> c) => !CarPassesThrough(c.A, c.BodyA, c.BodyB))
.OnPreSolve((in RoutedContact2D<SurfaceTag, VehicleTag> c) =>
{
var m = c.GetWorldManifold(); // normal: surface → vehicle
RecordSurface(c.B.Id, c.BodyB, m.Midpoint, m.Normal);
})
.OnPreSolve((in RoutedContact2D<VehicleTag, BallTag> c) =>
{
c.SetRestitution(0); // no passive bounce off cars
var m = c.GetWorldManifold(); // normal: vehicle → ball
RecordBall(c.A.Id, c.BodyA, c.BodyB, m.Midpoint, m.Normal);
})
.OnPostSolve((in RoutedContact2D<BallTag, SurfaceTag> c, float impulse) => Thud(impulse));
world.SetContactListener(router);
Order per pre-solve: tags resolved (a missing tag on either side: not routed) → every matching filter in registration order; the first false disables the contact and ends its routing → contacts without manifold points are not handled → every matching handler in registration order. Matching tries the unswapped orientation first (so for equal types the engine's A is A) and uses is, so base types and interfaces match. OnBegin, OnEnd and OnPostSolve route the other callbacks the same way. Nothing is allocated per contact.
Typed Contact Queries
// The world's touching contacts of a pair, in the world list order.
foreach (var c in world.TouchingContacts<SurfaceTag, VehicleTag>())
support[c.B.Id].Add(c.Normal); // normal out of the surface, toward the car
// One body's touching contacts with a tag type: the other side is A, this body B,
// so Normal points from the surface toward this body.
foreach (var c in world.TouchingContactsOf<SurfaceTag>(car.Body))
ground.Add(c.Normal, car.UpDir(), minDot);
Both yield only contacts that touch, are enabled and have manifold points. world.ContactsOf(body) lists one body's contacts in the world order restricted to the body. Box2D and planck.js both link a new contact first in the world's list and in each body's list, so this is also the order of planck's body.getContactList(): a client that walks a body's contact list and a server that filters the world list accumulate the same contacts in the same order (sums of normals come out with the same bits).
TypeScript
TypeScript has no runtime tag types, so a pair is two type guards. A planck World, Contact, Fixture and Body satisfy the structural interfaces as they are.
import { ContactRouter2D, touchingContacts, touchingContactsOf } from '@altruist/sim2d/physics';
const isSurface = (u: unknown): u is SurfaceUserData => (u as SurfaceUserData | undefined)?.kind === 'surface';
const isVehicle = (u: unknown): u is VehicleUserData => (u as VehicleUserData | undefined)?.kind === 'vehicle';
const router = new ContactRouter2D()
.filterPreSolve(isSurface, isBall, (c) => !ballPassesThrough(c.a, c.bodyA, c.bodyB))
.onPreSolve(isSurface, isVehicle, (c) => recordSurface(c.b.id, c.bodyB, c.point, c.normal))
.attach(world); // subscribes to pre-solve, begin/end-contact, post-solve
touchingContactsOf(car.body, isSurface, (c) => ground.add(c.normal, up, minDot));
The routed view passed to a callback is reused: valid only inside the callback. point is the average of the manifold points; normal is oriented like in C#.
State Machines for Match Phases
The state machine hosts match phases (countdown, playing, goal, replay, ended). A simulation that advances the phase clock at the start of the tick but evaluates the phase's transitions at the end calls the two halves of Update separately:
phases.Advance(match, dt); // TimeInState += dt (the phaseTime += dt at the top of Step)
// ... the tick's work, which may read match.TimeInState ...
phases.RunState(match, dt); // the phase handler; a returned state transitions, time back to 0
TransitionTo the current state re-enters it (exit, enter, time reset), the equivalent of SetPhase(samePhase) at a kickoff. The TypeScript twin (StateMachine, StateMachineBuilder, AIStateMachine, AIContext in @altruist/sim2d/flow) has the same semantics, so a client bot can mirror a server bot that runs on AIStateMachine.
Name Reference
C# (Altruist.Gaming.Flow) | TypeScript (@altruist/sim2d/flow) |
|---|---|
FirstMatch<TCtx, TResult> (When, Try, Otherwise) | FirstMatch (when, whenValue, try + NO_MATCH, otherwise, otherwiseValue) |
FirstMatch<TCtx> (Run) | FirstMatchActions (run) |
TickPipeline<TCtx>, EntitySteps<TCtx, TEntity> | TickPipeline, EntitySteps |
ModifierStack<TCtx>, ModifierOp | ModifierStack, ModifierOp ('set' ...) |
UtilitySelector<TOption, TCtx>, UtilityScores<TOption> | UtilitySelector (scores.of(option)) |
IdMask32, OverlapLatch32, OverlapLatch<TId> | IdMask32 (bit, has, withId, withoutId, collect), OverlapLatch32, OverlapLatch |
Countdown (Tick(ref t), TickRunning, CountUp), TimerSet | Countdown (tick, tickField, tickRunningField, countUp, countUpField), TimerSet |
ButtonEdges, InputLatch<T>, Stick | ButtonEdges, InputLatch, Stick |
EntityRegistry<T>, EntityRegistry<TKey, T> | EntityRegistry (number ids) |
IEventSink<T>, EventSink<T>, NullEventSink<T> | EventSinkLike, EventSink, nullEventSink |
StateMachine<T> (Advance, RunState), StateMachineBuilder<T>, AIStateMachine, AIContext | StateMachine (advance, runState), StateMachineBuilder, AIStateMachine, AIContext, StateContext helpers |
ContactRouter2D, RoutedContact2D<TA, TB>, ContactQueries2D (Altruist.Physx.TwoD) | ContactRouter2D, RoutedContact2D, touchingContacts, touchingContactsOf (@altruist/sim2d/physics) |
The TypeScript twins use JavaScript numbers (doubles) where C# uses float32: the operation order is the same, the bits are not.