Skip to content
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Server-side Minecraft Bedrock movement simulation library for Go.

`bedsim` replicates the Bedrock client's movement physics (collisions, stepping, edge-avoidance, liquids, gliding, teleportation) on the server, producing authoritative position and velocity values that can be compared against client-reported state.
`bedsim` replicates the Bedrock client's movement physics on the server, producing authoritative position and velocity values that can be compared against client-reported state. It covers collisions, stepping, edge avoidance, liquids and currents, swimming, bubble columns, Riptide, crawling, gliding, movement enchantments, movement-sensitive blocks, and teleportation.

Original code was written by [ethaniccc](https://github.com/ethaniccc) in [oomph](https://github.com/oomph-ac/oomph) and has been ported over into this library.
The liquid movement physics were ported from [oomph#145](https://github.com/oomph-ac/oomph/pull/145) by [NopeNotDark](https://github.com/NopeNotDark).
Expand All @@ -28,6 +28,7 @@ sim := bedsim.Simulator{
Liquids: myLiquidProvider, // second block layer (waterlogged blocks)
Effects: myEffectsProvider, // jump boost, levitation, slow falling
Inventory: myInventoryProvider, // elytra equipped check
Equipment: myEquipmentProvider, // movement enchantments and leather boots
Options: bedsim.SimulationOptions{
Mode: bedsim.SimulationModeAuthoritative,
PositionCorrectionThreshold: 0.5,
Expand All @@ -46,8 +47,9 @@ Set `BlockSemantics` when movement behavior must come from a per-world block
registry or custom block data instead of bedsim's Dragonfly-backed defaults.
The adapter implements `BlockMovementSemanticsProvider` and returns the full
`block.MovementSemantics` bundle: ground friction, any acceleration-only
friction multiplier, climbability, cobweb status, and slime/bed bounce
behavior. Built-in rules live in the
friction multiplier, Soul Speed interaction, climbability, cobweb status,
slime/bed bounce behavior, inside-block movement, and vertical traversal.
Built-in rules live in the
`github.com/oomph-ac/bedsim/block` package. Custom ground friction must be
finite and positive; an invalid value falls back to the built-in resolver. An
invalid acceleration multiplier likewise falls back to the built-in block
Expand All @@ -73,6 +75,37 @@ should affect water movement.
> discovered by type assertion, so a signature typo degrades silently — prefer
> the explicit field.

### Optional movement capabilities

`WorldProvider` is the only required world interface. A world may additionally
implement `BubbleColumnProvider` for upward/downward columns and
`MovementCollisionProvider` for player-dependent collision shapes such as
scaffolding and powder snow. Dynamic collision resolution receives sneak and
descend intent plus leather-boots state.

`MovementEquipmentProvider` supplies Depth Strider, Soul Speed, Swift Sneak,
Riptide, and leather-boots checks. The legacy `DepthStriderProvider` inventory
extension remains a fallback when the equipment provider reports no Depth
Strider level. `EffectsProvider` also controls Weaving-aware web movement.

Riptide input flags are not trusted on their own. Set `MovementState.RiptideReady`
for the simulation tick only after validating a charged Riptide-trident release.
Set `MovementState.RiptideCollision` after a server-observed entity collision to
authorize the corresponding stop/reversal; ordinary client stop flags are ignored.
Set `MovementState.RiptideInRain` from trusted weather exposure when rain should
permit launch without direct water contact.

Pose changes update `MovementState.Size`. Set `StandingHeight`,
`SneakingHeight`, or `CrawlingHeight` when using non-vanilla dimensions; zero
values preserve the current standing height and use vanilla crouch/crawl
heights.

Movement-sensitive block behavior includes honey blocks, sweet berry bushes,
powder snow, scaffolding, webs (including Weaving), soul sand with Soul
Speed, slime blocks, beds, climbables, fences/walls, and per-block friction.
Dynamic collision behavior still depends on the world adapter returning the
correct shapes for the current block state.

### Liquid movement

When the player's hitbox touches water or lava (and the player is not flying),
Expand Down
66 changes: 66 additions & 0 deletions bedrock_semantics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package bedsim

import (
"github.com/chewxy/math32"
"testing"

"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl32"
)

func TestBlockAirRecognisesOnlyBedrockAirIdentifier(t *testing.T) {
sim := &Simulator{BlockSemantics: encodedBlockSemantics{}}
tests := []struct {
name string
want bool
}{
{name: "minecraft:air", want: true},
{name: "minecraft:cave_air", want: false},
{name: "minecraft:void_air", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sim.blockAir(semanticsNamedBlock{name: tt.name}); got != tt.want {
t.Fatalf("blockAir(%q) = %v, want %v", tt.name, got, tt.want)
}
})
}
}

func TestJavaWebIdentifierHasNoBedrockMovementEffect(t *testing.T) {
w := environmentWorld{blocks: map[cube.Pos]world.Block{
{0, 0, 0}: semanticsNamedBlock{name: "minecraft:cobweb"},
}}
state := newBaseState()
state.Pos = mgl32.Vec3{0.5, 0, 0.5}
state.Vel = mgl32.Vec3{0.1, 0, 0}
state.HasGravity = false

result := (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).SimulateState(state)

if want := float32(0.1); math32.Abs(result.Movement.X()-want) > 1e-6 {
t.Fatalf("Java web identifier changed Bedrock movement: got %v, want %v", result.Movement.X(), want)
}
}

func TestDefaultSneakingHeightMatchesDragonflyBedrockPlayer(t *testing.T) {
state := newBaseState()

(&Simulator{}).applyInput(state, InputState{StartSneaking: true})

if state.Size.Y() != 1.49 {
t.Fatalf("sneaking height = %v, want 1.49", state.Size.Y())
}
}

func TestCrawlingCannotStartInOpenAir(t *testing.T) {
state := newBaseState()

(&Simulator{World: environmentWorld{}}).applyInput(state, InputState{StartCrawling: true})

if state.Crawling || state.Size.Y() != 1.8 {
t.Fatalf("open-air crawl was accepted: crawling=%v size=%v", state.Crawling, state.Size)
}
}
2 changes: 1 addition & 1 deletion block/contact.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import "github.com/df-mc/dragonfly/server/world"
type cobweb struct{}

func (cobweb) Matches(_ world.Block, name string) bool {
return name == "minecraft:web" || name == "minecraft:cobweb"
return name == "minecraft:web"
}

func (cobweb) Apply(s *resolution) {
Expand Down
20 changes: 20 additions & 0 deletions block/environment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package block

import "github.com/df-mc/dragonfly/server/world"

type environmentRule struct {
name string
inside InsideMovement
traversal Traversal
honey bool
}

func (r environmentRule) Matches(_ world.Block, name string) bool {
return name == r.name
}

func (r environmentRule) Apply(s *resolution) {
s.InsideMovement = r.inside
s.Traversal = r.traversal
s.Honey = r.honey
}
1 change: 1 addition & 0 deletions block/ground.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ func (soulSand) Matches(b world.Block, name string) bool {

func (soulSand) Apply(s *resolution) {
s.GroundAccelerationFrictionMultiplier *= SoulSandAccelerationFrictionMultiplier
s.SoulSpeedNeutralizesAccelerationFriction = true
}
37 changes: 32 additions & 5 deletions block/semantics.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,36 @@ const (
BounceBed
)

// InsideMovement identifies velocity changes applied while an entity overlaps
// a block's volume.
type InsideMovement uint8

const (
InsideMovementNone InsideMovement = iota
InsideMovementSweetBerryBush
InsideMovementPowderSnow
)

// Traversal identifies input-driven vertical movement supported by a block.
type Traversal uint8

const (
TraversalNone Traversal = iota
TraversalScaffolding
TraversalPowderSnow
)

// MovementSemantics is the movement behavior resolved for a block.
type MovementSemantics struct {
GroundFriction float32
GroundAccelerationFrictionMultiplier float32
Climbable bool
Cobweb bool
Bounce Bounce
GroundFriction float32
GroundAccelerationFrictionMultiplier float32
Climbable bool
Cobweb bool
Honey bool
Bounce Bounce
InsideMovement InsideMovement
Traversal Traversal
SoulSpeedNeutralizesAccelerationFriction bool
}

// rule contributes movement behavior for a block family.
Expand All @@ -47,6 +70,10 @@ var rules = [...]rule{
cobweb{},
slime{},
bed{},
environmentRule{name: "minecraft:honey_block", honey: true},
environmentRule{name: "minecraft:sweet_berry_bush", inside: InsideMovementSweetBerryBush},
environmentRule{name: "minecraft:powder_snow", inside: InsideMovementPowderSnow, traversal: TraversalPowderSnow},
environmentRule{name: "minecraft:scaffolding", traversal: TraversalScaffolding},
frictionBlock{name: "minecraft:ice", friction: 0.98},
frictionBlock{name: "minecraft:packed_ice", friction: 0.98},
frictionBlock{name: "minecraft:blue_ice", friction: 0.989},
Expand Down
118 changes: 118 additions & 0 deletions block_effects.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package bedsim

import (
"github.com/chewxy/math32"

"github.com/df-mc/dragonfly/server/block/cube"
"github.com/go-gl/mathgl/mgl32"
movementblock "github.com/oomph-ac/bedsim/block"
)

func applyInsideBlockMovement(state *MovementState, movement movementblock.InsideMovement) {
switch movement {
case movementblock.InsideMovementSweetBerryBush:
queueStuckSpeedMultiplier(state, mgl32.Vec3{0.8, 0.75, 0.8})
case movementblock.InsideMovementPowderSnow:
queueStuckSpeedMultiplier(state, mgl32.Vec3{0.9, 1.5, 0.9})
}
}

func queueStuckSpeedMultiplier(state *MovementState, multiplier mgl32.Vec3) {
queued := state.StuckSpeedMultiplier
if queued.LenSqr() <= 1e-7 {
state.StuckSpeedMultiplier = multiplier
return
}
for axis := range 3 {
queued[axis] = min(queued[axis], multiplier[axis])
}
state.StuckSpeedMultiplier = queued
}

func applyStuckSpeedMultiplier(state *MovementState) bool {
multiplier := state.StuckSpeedMultiplier
if multiplier.LenSqr() <= 1e-7 {
return false
}
if state.NoClip {
state.StuckSpeedMultiplier = mgl32.Vec3{}
return false
}
state.SetVel(mgl32.Vec3{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only scales the existing velocity, but the slowdown also applies to the pending movement request. With a queued berry/powder-snow multiplier, a zero-velocity player with new input reaches moveRelative unchanged, and the later stuckMovement branch only clears velocity after recording the full displacement. Scale the pending horizontal impulse/request too and add a two-tick input-driven regression test.

state.Vel.X() * multiplier.X(),
state.Vel.Y() * multiplier.Y(),
state.Vel.Z() * multiplier.Z(),
})
state.StuckSpeedMultiplier = mgl32.Vec3{}
return true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func applyAscendableMovement(state *MovementState, traversal movementblock.Traversal, leatherBoots bool) {
velocity := state.Vel
switch traversal {
case movementblock.TraversalScaffolding:
if state.PressingDescend {
velocity[1] = -0.15
} else if state.PressingAscend {
velocity[1] = 0.15
}
case movementblock.TraversalPowderSnow:
if state.PressingDescend {
velocity[1] = -0.15
} else if state.PressingAscend && leatherBoots {
velocity[1] = 0.2
}
}
state.SetVel(velocity)
}

func (s *Simulator) applyInsideBlockEffects(state *MovementState) {
if s.World == nil {
return
}
bb := state.BoundingBox(s.Options.UseSlideOffset)
min, maxPoint := bb.Min(), bb.Max()
for x := int(math32.Floor(min.X())); x < int(math32.Ceil(maxPoint.X())); x++ {
for y := int(math32.Floor(min.Y())); y < int(math32.Ceil(maxPoint.Y())); y++ {
for z := int(math32.Floor(min.Z())); z < int(math32.Ceil(maxPoint.Z())); z++ {
pos := cube.Pos{x, y, z}
if !bb.IntersectsWith(cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))) {
continue
}
b := s.World.Block(pos)
if s.blockAir(b) {
continue
}
semantics := s.blockMovementSemantics(b)
applyInsideBlockMovement(state, semantics.InsideMovement)
}
}
}
s.applyHoneyWallSlide(state)
}

func (s *Simulator) applyHoneyWallSlide(state *MovementState) {
if !state.CollideX && !state.CollideZ {
return
}
bb := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{1e-3, 0, 1e-3})
min, maxPoint := bb.Min(), bb.Max()
for x := int(math32.Floor(min.X())); x < int(math32.Ceil(maxPoint.X())); x++ {
for y := int(math32.Floor(min.Y())); y < int(math32.Ceil(maxPoint.Y())); y++ {
for z := int(math32.Floor(min.Z())); z < int(math32.Ceil(maxPoint.Z())); z++ {
pos := cube.Pos{x, y, z}
if !bb.IntersectsWith(cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))) {
continue
}
if s.blockMovementSemantics(s.World.Block(pos)).Honey {
velocity := state.Vel
velocity[0] *= 0.4
velocity[1] = max(-0.12, velocity[1])
velocity[2] *= 0.4
state.SetVel(velocity)
return
}
}
}
}
}
Loading