Simulation API
The simulation API is for short, tick-based decisions where a plugin needs to choose a movement tile or action signal before clicking.
Use it for things like:
- Dodging projectile or ground hazards.
- Choosing a tile near, away from, or in line with an NPC.
- Predicting moving threats such as tornadoes.
- Checking whether a melee NPC can reach or attack future player positions.
- Scoring attack-ready tiles without hardcoding one fixed movement pattern.
It is not a replacement for Navigation. Simulations are small, local planners. They work best when you run them from current game state each tick and execute only the selected first step.
Basic Shape
A Simulation<I> has:
I: your plugin-defined input record for one run.SimulationOptions: local grid size, max ticks, diagonal movement, and movement speed.SimulationTasks: ordered rules that block, penalize, avoid, score, or attach action signals to candidate nodes.
import dev.twilite.game.common.Coord;
import dev.twilite.game.simulation.Simulation;
import dev.twilite.game.simulation.model.MoveSpeed;
import dev.twilite.game.simulation.model.SimulationOptions;
import dev.twilite.game.simulation.model.SimulationResult;
private final Simulation<FightInput> simulation = Simulation.<FightInput>create(
SimulationOptions.grid(15, 15)
.maxTicks(8)
.moveSpeed(MoveSpeed.RUNNING)
).withTasks(
// Rules go here.
);
public void tick(FightInput input) {
simulation.run(input, input.player())
.filter(SimulationResult::moved)
.ifPresent(result -> {
Coord next = input.origin().translate(result.firstStep().x(), result.firstStep().y());
// Navigation.step(next), Walking.step(next), or your plugin's movement call.
});
}
The simulation grid is local. In most plugins, keep an origin world coordinate and convert world state into grid coordinates before running the simulation.
public record FightInput(
Coord origin,
Coord player,
Collection<Coord> dangerousTiles
) {
public Coord toLocal(Coord world) {
return new Coord(world.x() - origin.x(), world.y() - origin.y(), world.floor());
}
public Coord toWorld(Coord local) {
return origin.translate(local.x(), local.y());
}
}
Options
SimulationOptions.grid(width, height) creates a bounded grid with diagonal movement enabled, walking movement, and a default max tick depth of width * height.
Set the values explicitly for combat plugins:
SimulationOptions options = SimulationOptions.grid(17, 17)
.maxTicks(6)
.diagonals(true)
.moveSpeed(MoveSpeed.RUNNING);
Movement speeds:
MoveSpeed.CRAWLING: one step every two ticks.MoveSpeed.WALKING: one step per tick.MoveSpeed.RUNNING: two steps per tick.new MoveSpeed(3.0): custom speeds for NPCs or special movement.
Threats
Coordinate threats add penalty to matching nodes and can mark nodes as bad final destinations.
import dev.twilite.game.simulation.rules.Rules;
import dev.twilite.game.simulation.model.threat.SimulationThreat;
private final Rules<FightInput> rules = Rules.typed();
private final Simulation<FightInput> simulation = Simulation.<FightInput>create(options).withTasks(
rules.threats().from(input -> input.dangerousTiles().stream()
.map(tile -> SimulationThreat.at(tile)
.always()
.radius(0)
.penalty(25)
.avoid())
.toList()
).build()
);
Use penalize() when standing there is allowed but undesirable. Use avoid() when the tile should remain reachable for pathing but should not be chosen as the final movement destination.
Timed threats use active(startTick, duration).
SimulationThreat.at(landingTile)
.active(3, 2)
.radius(1)
.penalty(80)
.avoid();
That threat is active on simulated ticks 3 and 4.
Area Threats
Use area threats for boss footprints, slam zones, waves, or rectangular danger areas.
import dev.twilite.game.common.Rect;
import dev.twilite.game.simulation.model.threat.SimulationAreaThreat;
rules.areaThreats().from(input -> List.of(
SimulationAreaThreat.covering(input.bossArea())
.always()
.penalty(60)
.avoid()
)).build();
This rejects final destinations inside the boss area. If you only want to prefer not standing there, call penalize().
Moving Threats
Moving threats are path-dependent. The threat moves differently depending on the simulated player path, so the rule needs a stepper.
import dev.twilite.game.simulation.model.threat.SimulationMovingThreat;
rules.movingThreats().from(input -> input.tornadoes().stream()
.map(tornado -> SimulationMovingThreat.at(tornado)
.radius(0)
.penalty(100)
.avoid())
.toList())
.stepWith(this::stepToward);
private Coord stepToward(Coord threat, Coord target) {
int dx = Integer.compare(target.x(), threat.x());
int dy = Integer.compare(target.y(), threat.y());
return threat.translate(dx, dy);
}
Use this for simple tornadoes, projectiles that track the player, or NPC-like hazards that do not need full combat prediction.
Preferences
Preferences add score. The simulator selects the lowest penalty first, then the highest score.
Prefer tiles near an area:
import dev.twilite.game.simulation.rules.preference.AreaPreference;
AreaPreference.nearest(FightInput::attackArea)
.score(20)
.distancePenalty(3);
Prefer staying in a clear line:
rules.linePreference()
.score(2)
.endBonus(4);
Preferences should not be used for hard safety. Use threats or blocking rules for safety.
Attack Windows
AttackWindow attaches a PLAYER_ATTACK action signal to candidates where the caller can attack.
import dev.twilite.game.simulation.rules.action.AttackWindow;
import dev.twilite.game.simulation.model.action.SimulationActionType;
AttackWindow.when(FightInput::ticksUntilAttack)
.canAttack(node -> node.penalty() == 0 && !node.avoid())
.score(50);
Handle the result:
simulation.run(input, input.player()).ifPresent(result -> {
if (result.action(SimulationActionType.PLAYER_ATTACK)) {
attackBoss();
return;
}
if (result.moved()) {
move(input.toWorld(result.firstStep()));
}
});
This keeps movement and attack decisions in one result without making the movement simulator perform the click itself.
Entity Combat
Use EntityCombat when an entity's future position and attack timing depend on the simulated player position.
import dev.twilite.game.common.Rect;
import dev.twilite.game.simulation.model.entity.SimulationEntity;
rules.entityCombat().from(input -> List.of(
SimulationEntity.melee(
input.bossId(),
input.bossArea(),
1,
4
)
)).avoid(75);
The default predictor moves entities directly toward the candidate target, respects simple entity collision between simulated entities, and checks SimulationEntity.canAttack(...).
Customize entity state with builder-like copy methods:
SimulationEntity.melee(npc.index(), npc.area(), 1, 4)
.moveSpeed(MoveSpeed.RUNNING)
.attackCooldown(ticksUntilAttack)
.movementBlocked(2)
.hittingInside();
Use a custom predictor when the NPC has special movement:
rules.entityCombat().from(FightInput::meleeNpcs)
.predictWith((input, entity, target, tick, collision) -> {
SimulationEntity predicted = entity;
for (int i = 0; i < tick; i++) {
predicted = customNpcStep(input, predicted, target, collision);
}
return predicted;
})
.lineOfSight(SimulationEntity.LineOfSight.world())
.avoid(100);
For local-grid simulations, avoid LineOfSight.world() unless your simulation coordinates are real world coordinates. Provide your own line-of-sight rule when you simulate in local coordinates.
Custom Rules
Implement SimulationTask<I> when the provided rules are not enough.
import dev.twilite.game.simulation.SimulationTask;
import dev.twilite.game.simulation.model.SimulationGrid;
import dev.twilite.game.simulation.model.SimulationNode;
public class SafeTileRule implements SimulationTask<FightInput> {
@Override
public boolean step(FightInput input, SimulationGrid grid, Coord from, Coord to, int tick) {
return input.collision().canWalk(from, to);
}
@Override
public void visit(FightInput input, SimulationGrid grid, SimulationNode node) {
if (input.badTiles().contains(node.position())) {
node.avoid(true);
node.addPenalty(100);
}
}
}
Useful hooks:
onTick(...): update external cached state before the run.begin(...): precompute values for this input/run.step(...): allow or reject movement from one coordinate to another.visit(...): score or penalize reachable nodes.end(...): final scoring pass after all nodes have been visited.
Overlaying Results
Store the last result if you want to debug it with an overlay.
private SimulationResult lastResult;
public void tick() {
lastResult = simulation.run(input, input.player()).orElse(null);
}
Then draw lastResult.path() or lastResult.destination().position() in your overlay. Keep this debug-only; do not use a stored result for future ticks without rerunning the simulation.
Rollout Search
Simulation merges candidates by coordinate. That is correct for movement planning, but sometimes each branch needs its own state: cooldowns, projectiles, NPC positions, or inventory actions.
Use RolloutSearch<S, A> for branch-local state search.
import dev.twilite.game.simulation.rollout.RolloutOptions;
import dev.twilite.game.simulation.rollout.RolloutSearch;
private final RolloutSearch<FightState, FightAction> search =
RolloutSearch.<FightState, FightAction>create(
RolloutOptions.depth(8)
.iterations(300)
.exploration(0.4)
)
.actions(FightState::legalActions)
.transition(FightState::apply)
.evaluate(node -> node.state().score())
.terminal(node -> node.state().finished())
.build();
public void tick(FightState state) {
search.run(state)
.flatMap(result -> result.firstAction())
.ifPresent(this::execute);
}
Use rollout search for higher-level decisions. Use Simulation for local tile selection.
Common Pitfalls
Run the simulation every tick. Game state changes constantly.
Use local coordinates consistently. If your input stores local coordinates, convert every projectile, NPC, area, and player tile into the same local grid before running.
Keep the grid small. A 15x15 or 21x21 local grid is usually enough for combat movement. Large grids waste time and make scoring harder to reason about.
Model hard safety as threats or blocking, not preferences. Preferences only break ties after penalty.
Use avoid() for destinations that are bad but may need to be crossed. Use block() or a custom step(...) rule when the coordinate cannot be traversed at all.
Execute only the first step. SimulationResult.path() is for debugging and inspection. In a plugin tick, move to firstStep() and let the next tick recalculate.