Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,14 @@ Examples will be added as the project undergoes more development.
| --- | --- |
| [API](https://github.com/GenerelSchwerz/minecraft-pathfinding/blob/main/docs/API.md) | The documentation with the available methods and properties. |
| [Advanced Usage](https://github.com/GenerelSchwerz/minecraft-pathfinding/blob/main/docs/AdvancedUsage.md) | The documentation with the advanced usage of the pathfinder, including the customization of goals and movements. |
| [Movement Costs](https://github.com/GenerelSchwerz/minecraft-pathfinding/blob/main/docs/MovementCosts.md) | How move costs and the A\* heuristic work (tick-based, credited to Baritone). |
| [Examples](https://github.com/GenerelSchwerz/minecraft-pathfinding/tree/main/examples) | The folder with the examples. |

<h3 align="center">Credits</h3>

-----

The movement **cost constants** and the A\* **heuristic shape** are taken from
[Baritone](https://github.com/cabaletta/baritone), the Minecraft pathfinding mod
by Leijurv and contributors (LGPL-3.0). See its `ActionCosts`, `GoalXZ` and
`GoalYLevel`. Details, and where we deliberately differ, are in [Movement Costs](https://github.com/GenerelSchwerz/minecraft-pathfinding/blob/main/docs/MovementCosts.md).
127 changes: 75 additions & 52 deletions docs/MovementCosts.md
Original file line number Diff line number Diff line change
@@ -1,119 +1,142 @@
<h1 align="center">Movement Costs</h1>

How the pathfinder decides which way to go: every possible move is given a
How the pathfinder decides which way to go. Every possible move is given a
**cost**, and A\* looks for the route with the smallest total cost.

This page explains what that cost means, where the numbers come from, and the
few knobs you can turn.

- [The big idea: cost = time](#the-big-idea-cost--time)
- [Cost means time](#cost-means-time)
- [The tick constants](#the-tick-constants)
- [What each move costs](#what-each-move-costs)
- [The heuristic (A\*'s guess)](#the-heuristic-as-guess)
- [The heuristic](#the-heuristic)
- [Knobs you can tune](#knobs-you-can-tune)

## The big idea: cost = time
## Cost means time

A cost is **how long the bot is busy doing the move, measured in game ticks**.

Minecraft runs at **20 ticks per second**, so one tick is 1/20 of a second.
Lower cost = faster = better.
Lower cost means faster, which means better.

Everything is measured in the same unit (ticks), which is the whole point. When
walking, jumping, falling, and digging are all expressed as *time*, the
pathfinder can compare them honestly. It can answer questions like "is it faster
to dig straight through this hill, or walk around it?" — because both options are
just numbers of ticks.

> Before this, moves used made-up numbers (walk = `1`, diagonal = `1.41`, a jump
> was `+0.5`, a 3-block drop was `+1.5`, ...). Those numbers had no real-world
> meaning, so the bot sometimes preferred routes that *looked* cheap but were
> actually slow.

The numbers come from [Baritone](https://github.com/cabaletta/baritone), a
mature Minecraft pathfinder. They are hardcoded estimates (`20 / speed`) rather
than a live physics simulation — close enough to reality, and very fast to
compute. They live in
walking, jumping, falling and digging are all expressed as *time*, the pathfinder
can compare them honestly. It can answer a question like "is it faster to dig
straight through this hill or walk around it", because both options are just
numbers of ticks.

> Before this, moves used made-up numbers (walk was `1`, diagonal `1.41`, a jump
> `+0.5`, a 3-block drop `+1.5`). Those numbers had no real-world meaning, so the
> bot sometimes preferred routes that *looked* cheap but were actually slow.

### Credit

These cost numbers, and the heuristic shape further down, come from
[**Baritone**](https://github.com/cabaletta/baritone), the Minecraft pathfinding
mod by **Leijurv and contributors** (licensed **LGPL-3.0**). The constants come
from its `ActionCosts` interface
(`src/main/java/baritone/pathing/movement/ActionCosts.java`), and the heuristic
from its `GoalXZ` and `GoalYLevel`. They are hardcoded estimates (`20 / speed`)
rather than a live physics simulation, so they are close to reality and very
fast to compute. They live in
[`src/mineflayer-specific/movements/costs.ts`](../src/mineflayer-specific/movements/costs.ts).

## The tick constants

| Constant | Ticks | Meaning |
| --- | --- | --- |
| `SPRINT_ONE_BLOCK_COST` | 3.564 | Sprint one block (fastest ground travel). |
| `SPRINT_ONE_BLOCK_COST` | 3.564 | Sprint one block, the fastest ground travel. |
| `WALK_ONE_BLOCK_COST` | 4.633 | Walk one block. |
| `WALK_OFF_BLOCK_COST` | 3.706 | Step off the edge of a block (start of a drop). |
| `CENTER_AFTER_FALL_COST` | 0.927 | Re-center after landing. |
| `JUMP_ONE_BLOCK_COST` | 3.163 | The upward arc of a jump. |
| `LADDER_UP_ONE_COST` | 8.511 | Climb up one block on a ladder/vine. |
| `LADDER_UP_ONE_COST` | 8.511 | Climb up one block on a ladder or vine. |
| `LADDER_DOWN_ONE_COST` | 6.667 | Climb down one block. |
| `WALK_ONE_IN_WATER_COST` | 9.091 | Swim/walk one block while in water. |
| `WALK_ONE_IN_WATER_COST` | 9.091 | Swim or walk one block while in water. |
| `WALK_ONE_OVER_SOUL_SAND_COST` | 9.266 | Walk one block over soul sand. |
| `SNEAK_ONE_BLOCK_COST` | 15.385 | Sneak one block. |
| `FALL_N_BLOCKS_COST[n]` | table | Ticks to fall `n` blocks (not linear you speed up). |
| `FALL_N_BLOCKS_COST[n]` | table | Ticks to fall `n` blocks. Not linear, because you speed up as you fall. |
| `COST_INF` | 1,000,000 | "Impossible." Any move this expensive is thrown away. |

## What each move costs

Each movement provider in
[`movementProviders.ts`](../src/mineflayer-specific/movements/movementProviders.ts)
adds up the ticks for the thing it does:
adds up the ticks for the thing it does.

| Move | Base cost (ticks) |
| --- | --- |
| **Forward** | `travelCost(1)`sprint/walk one block. |
| **Diagonal** | `travelCost(√2)` a diagonal block is √2 blocks of travel. |
| **ForwardJump** (up 1) | `max(JUMP, WALK)` — you rise and move at once, so it's the slower of the two, not the sum. |
| **Forward** | `travelCost(1)`, sprint or walk one block. |
| **Diagonal** | `travelCost(√2)`, because a diagonal block is √2 blocks of travel. |
| **ForwardJump** (up 1) | `max(JUMP, WALK)`. You rise and move at the same time, so it is the slower of the two, not the sum. |
| **ForwardDropDown** | `WALK_OFF_BLOCK_COST + FALL_N_BLOCKS_COST[height]`. |
| **StraightDown** | `FALL_N_BLOCKS_COST[height]`pure falling, no sideways travel. |
| **StraightUp** | ladder → `LADDER_UP_ONE_COST`; otherwise `JUMP_ONE_BLOCK_COST` + a place cost to tower up. |
| **ParkourForward** | `JUMP_ONE_BLOCK_COST + travelCost(gap)` the jump plus sprinting across the gap. |
| **StraightDown** | `FALL_N_BLOCKS_COST[height]`, pure falling with no sideways travel. |
| **StraightUp** | On a ladder, `LADDER_UP_ONE_COST`. Otherwise `JUMP_ONE_BLOCK_COST` plus a place cost to tower up. |
| **ParkourForward** | `JUMP_ONE_BLOCK_COST + travelCost(gap)`, the jump plus sprinting across the gap. |
| **ParkourDiagonal** | `JUMP_ONE_BLOCK_COST + travelCost(gap)`. |

On top of the base cost a move also pays for any blocks it must **break**
(`breakCost`, the block's real mining time in ticks) or **place**
(`placeCost`). `travelCost(blocks)` is a small helper on `Movement` that returns
(`breakCost`, the block's real mining time in ticks) or **place** (`placeCost`).
`travelCost(blocks)` is a small helper on `Movement` that returns
`blocks × (sprinting ? SPRINT : WALK)`.

## The heuristic (A\*'s guess)
## The heuristic

A\* also needs to *guess* the remaining cost from any point to the goal. That
guess is the **heuristic** (in [`goals.ts`](../src/mineflayer-specific/goals.ts)):
guess is the **heuristic** (in [`goals.ts`](../src/mineflayer-specific/goals.ts)).
Its shape is Baritone's (`GoalXZ` and `GoalYLevel`). It splits the guess into a
horizontal part and a vertical part instead of one 3D straight-line distance,
because the bot moves that way. It travels along the 8 compass directions on the
ground, and up or down is a separate jump or fall.

```
heuristic = straight_line_distance × COST_HEURISTIC
```
heuristic = heuristicXZ(dx, dz) + heuristicY(dy)

`COST_HEURISTIC` is the **walk** cost per block (4.633), not the cheaper
**sprint** cost (3.564). That choice is deliberate:
heuristicXZ split into a diagonal run (shorter axis, √2 per block) and a
straight run (the rest), then times COST_HEURISTIC (octile)
heuristicY going up costs dy × JUMP_ONE_BLOCK_COST
going down costs dy × FALL_N_BLOCKS_COST[2] / 2
```

- A\* finds the *truly* shortest route only when its guess never costs more than
the cheapest real move (sprinting). Guessing with the slightly-higher walk
cost makes A\* a lightly **"weighted"** search: it leans harder toward the
goal and explores far fewer dead-ends, so it stays **fast**.
- The price is that returned paths can be up to `walk / sprint = 4.633 / 3.564
≈ 1.3×` the perfect optimum. That is a small, bounded amount.
- Back when move costs were made-up numbers (~1 per block) but the heuristic was
already ~4.633 per block, that same ratio was ~**4.6×** — paths could be far
from optimal. Putting move costs in ticks shrinks the bound from ~4.6× to
~1.3× **without making the search any slower.**
### Walk weight vs sprint weight (where we differ from Baritone)

In short: same speed, much better paths.
`COST_HEURISTIC` is the **walk** cost per block (4.633), not the cheaper
**sprint** cost (3.564). **Baritone uses sprint** (its `costHeuristic` default is
about 3.563), which never over-estimates the cheapest move, so Baritone's A\* is
guaranteed to return the optimal path. We deliberately use walk instead, and the
reason is measured, not guessed.

- This A\* uses a plain binary heap with **no f-tie-breaker**. With the admissible
sprint weight, open ground fills with a plateau of equal-cost nodes and the
search floods through them. On a flat `150×40` goal that was about **4300 nodes
visited**. The walk weight visits about **150** for the same path, roughly
**28×** less work. Baritone dodges this with tie-breaking and short path
segments that this implementation does not have.
- Using walk runs a lightly **weighted** A\*. It leans toward the goal and
beelines, which is fast. The price is that paths can be up to about `1.3×`
(`walk / sprint`) the optimum, which is small and bounded.
- Before move costs were in ticks (about 1 per block) the heuristic was already
about 4.633, so that ratio was about **4.6×**. Tick costs already shrank it to
about **1.3×**.

In short, we took Baritone's heuristic shape, which is a clear win, and kept a
walk weight for speed because our A\* cannot afford the admissible version. Node
counts stay as fast as before, with much better path quality.

## Knobs you can tune

Pass these in `moveSettings` (see [AdvancedUsage](./AdvancedUsage.md)). All are
in ticks; the defaults are tuned to match reality, so you rarely need to change
in ticks. The defaults are tuned to match reality, so you rarely need to change
them.

| Option | Default | What it does |
| --- | --- | --- |
| `liquidCost` | `WALK_ONE_IN_WATER - WALK_ONE_BLOCK` ( 4.46) | Extra ticks per block for moving through water. |
| `liquidCost` | `WALK_ONE_IN_WATER - WALK_ONE_BLOCK` (about 4.46) | Extra ticks per block for moving through water. |
| `placeCost` | `20` | Ticks charged for placing a block. Placing interrupts movement, so it is deliberately expensive (Baritone's value). |
| `digCost` | `1` | Multiplier on a block's real mining time. Raise it to make digging look worse and prefer going around. |
| `jumpCost` | `0` | Extra ticks per jump on top of the physical jump arc. Raise it to discourage jumpy paths. |

Example — make the bot hate digging and never want to swim:
An example that makes the bot hate digging and never want to swim:

```js
bot.pathfinder.setMoveOptions({
Expand Down
90 changes: 63 additions & 27 deletions src/mineflayer-specific/goals.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Vec3 } from 'vec3'
import { Goal as AGoal } from '../abstract'
import { Move } from './move'
import { COST_HEURISTIC } from './movements/costs'
import { COST_HEURISTIC, FALL_N_BLOCKS_COST, JUMP_ONE_BLOCK_COST } from './movements/costs'
import { World } from './world/worldInterface'
import { AABB } from '@nxg-org/mineflayer-util-plugin'
import { PlaceHandler } from './movements/interactionUtils'
Expand All @@ -11,6 +11,53 @@ import type { Block } from '../types'
import { BotEvents } from 'mineflayer'
import type { Entity } from 'prismarine-entity'

// ===========================================================================
// A* heuristic helpers.
//
// CREDIT: ported from Baritone (Leijurv & contributors,
// https://github.com/cabaletta/baritone, LGPL-3.0) so our estimate has the same
// shape as our real move costs (everything in game ticks):
// GoalXZ.calculate -> heuristicXZ (horizontal, "octile" distance)
// GoalYLevel.calculate -> heuristicY (vertical: jump up / fall down)
//
// Splitting horizontal and vertical (instead of one 3D straight-line distance)
// matches how the bot actually moves: it can only travel along the 8 compass
// directions on the ground, and up/down is a separate jump or fall.
//
// NOTE: Baritone scales this by the SPRINT cost (admissible). We scale by the
// WALK cost instead. See COST_HEURISTIC in costs.ts for the measured reason
// (our heap has no tie-breaker, so the admissible version explores ~28x more).
// ===========================================================================

/**
* Horizontal part of the heuristic (Baritone `GoalXZ.calculate`).
*
* A horizontal trip is made of a diagonal run (along the shorter axis, where
* one step covers both x and z) plus a straight run for whatever is left over.
* A diagonal block is `sqrt(2)` blocks of travel. The whole thing is scaled by
* {@link COST_HEURISTIC} ticks per block.
*/
function heuristicXZ (dx: number, dz: number): number {
const x = Math.abs(dx)
const z = Math.abs(dz)
const diagonal = Math.min(x, z)
const straight = Math.max(x, z) - diagonal
return (diagonal * Math.SQRT2 + straight) * COST_HEURISTIC
}

/**
* Vertical part of the heuristic (Baritone `GoalYLevel.calculate`).
*
* `dy` is `goalY - nodeY`: positive means the goal is above us (we must climb,
* roughly one jump per block) and negative means it is below us (we fall, which
* is cheaper per block, about half of a 2-block fall).
*/
function heuristicY (dy: number): number {
if (dy > 0) return dy * JUMP_ONE_BLOCK_COST
if (dy < 0) return -dy * (FALL_N_BLOCKS_COST[2] / 2)
return 0
}

/**
* The abstract goal definition used by the pathfinder.
*/
Expand Down Expand Up @@ -279,21 +326,19 @@ export class GoalBlock extends Goal {
}

heuristic (node: Move): number {
// return 0;
const dx = this.x - node.x
const dy = this.y - node.y
const dz = this.z - node.z
return Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC
// return (Math.sqrt(dx * dx + dz * dz) + Math.abs(dy))
// return distanceXZ(dx, dz) + Math.abs(dy)
// Baritone-style: horizontal (octile) + vertical (jump up / fall down).
return heuristicXZ(this.x - node.x, this.z - node.z) + heuristicY(this.y - node.y)
}

distHeuristic (node: Move): number {
// Raw block distance (NOT ticks). The only consumer, PartialPathProducer's
// maxPathLength, compares this to a move COUNT, so it must match every other
// goal's distHeuristic (which all return raw blocks). The stray COST_HEURISTIC
// here inflated GoalBlock's partial-segment cap ~4.6x vs other goal types.
const dx = this.x - node.x
const dy = this.y - node.y
const dz = this.z - node.z
const distance = Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC
return distance
return Math.sqrt(dx * dx + dz * dz + dy * dy)
}

isEnd (node: Move): boolean {
Expand Down Expand Up @@ -330,10 +375,7 @@ export class GoalNear extends Goal {
}

heuristic (node: Move): number {
const dx = this.x - node.x
const dy = this.y - node.y
const dz = this.z - node.z
return Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC
return heuristicXZ(this.x - node.x, this.z - node.z) + heuristicY(this.y - node.y)
}

distHeuristic (node: Move): number {
Expand Down Expand Up @@ -362,9 +404,7 @@ export class GoalNearXZ extends Goal {
}

heuristic (node: Move): number {
const dx = this.x - node.x
const dz = this.z - node.z
return Math.sqrt(dx * dx + dz * dz) * COST_HEURISTIC
return heuristicXZ(this.x - node.x, this.z - node.z)
}

distHeuristic (node: Move): number {
Expand Down Expand Up @@ -416,10 +456,7 @@ export class GoalLookAt extends Goal {
}

heuristic (node: Move): number {
const dx = this.x - node.x
const dy = this.y - (node.y + this.eyeHeight) // eye level
const dz = this.z - node.z
return Math.sqrt(dx * dx + dz * dz + dy * dy) * COST_HEURISTIC
return heuristicXZ(this.x - node.x, this.z - node.z) + heuristicY(this.y - (node.y + this.eyeHeight))
Comment thread
XaXayo12 marked this conversation as resolved.
}

distHeuristic (node: Move): number {
Expand All @@ -434,7 +471,10 @@ export class GoalLookAt extends Goal {
* TODO: account for entity collision (prismarine-world currently does not support this).
*/
isEnd (node: Move): boolean {
const dist = this.heuristic(node)
// Gate on real block distance, not the tick heuristic. heuristic() is now an
// octile+vertical TICK estimate (e.g. ~7.6 for a 1.3-block diagonal look),
// which would wrongly fail this `> distance + 3` check before the raycast.
const dist = this.distHeuristic(node)

if (dist > this.distance + 3) return false
const pos = new Vec3(node.x, node.y + this.eyeHeight, node.z)
Expand Down Expand Up @@ -556,11 +596,7 @@ export class GoalFollowEntity extends GoalDynamic<'entityMoved', 'entityGone'> {
}

heuristic (node: Move): number {
const dx = this.x - node.x
const dy = this.y - node.y
const dz = this.z - node.z

return Math.sqrt(dx * dx + dy * dy + dz * dz) * COST_HEURISTIC
return heuristicXZ(this.x - node.x, this.z - node.z) + heuristicY(this.y - node.y)
}

distHeuristic (node: Move): number {
Expand Down
Loading
Loading