Skip to content

v0.11.0 Migration Guide

github-actions edited this page Jul 13, 2026 · 1 revision

v0.11.0 Migration Guide

The blocking transaction entry points World.Exec and EntityHandle.ExecWorld are removed. World work is now scheduled onto the world's owner: the single goroutine that runs all of a world's transactions, ticking included. Any code that receives a *world.Tx is already on the owner.

From any goroutine, work is scheduled with Do and DoAfter (fire-and-forget). From inside a callback, tx.Defer schedules follow-up work on the same owner. Code that is not on the owner and needs a result uses world.Call, world.CallEntity or world.CallRef. Every scheduled call returns a *world.Task that reports success or failure (see "Handling failures").

What changes

  • Awaiting Exec from inside a handler, command or packet callback could deadlock the world permanently, and whether a given function was "inside" was invisible at the call site. Do and Defer are safe from any goroutine.
  • Work scheduled against a world or entity that closes first fails with a defined error (ErrWorldClosed, ErrEntityClosed, and so on) on the returned task, rather than hanging or silently not running.
  • Delayed entity work follows the entity: a task scheduled on a player who travels through a portal runs in the world they arrive in.
  • player.Ref and world.EntityRef[T] hand callbacks a typed value directly, removing the e.(*player.Player) casts.
  • A panicking fire-and-forget callback is recovered, logged with its stack through the world's logger, and recorded on the task. The synchronous Call functions re-panic on the calling goroutine, like a normal function call.
  • Each handler event dispatch gets its own cancel scope, so cancelling one event cannot affect another fired in the same tick.
  • Existing *world.Tx signatures are unchanged. The one rename in handler bodies is ctx.Val() → ctx.Player().

Fire-and-forget world work

// Before — blocks, and deadlocks if already on this world's owner:
<-w.Exec(func(tx *world.Tx) {
    tx.SetBlock(pos, block.Stone{}, nil)
})

// After — returns immediately, safe from anywhere:
w.Do(func(tx *world.Tx) {
    tx.SetBlock(pos, block.Stone{}, nil)
})

Blocking calls with results (off-owner only)

For goroutines, startup code and tests — never from inside an owner callback, where it deadlocks.

// Before — hand-rolled capture through a closed-over variable:
var count int
<-w.Exec(func(tx *world.Tx) { count = len(slices.Collect(tx.Entities())) })

// After:
count, err := world.Call(ctx, w, func(tx *world.Tx) (int, error) {
    return len(slices.Collect(tx.Entities())), nil
})

The same shape exists for entities and players: world.CallEntity(ctx, handle, f), world.CallRef(ctx, ref, f) and player.Call(ctx, handle, f).

Delayed entity work

// Before — manual re-entry that breaks if the entity changes worlds:
time.AfterFunc(time.Second, func() {
    handle.ExecWorld(func(tx *world.Tx, e world.Entity) { ... })
})

// After — follows the entity, fails with a defined error if it is gone:
handle.DoAfter(time.Second, func(tx *world.Tx, e world.Entity) { ... })

Reaching a player from outside a callback

// Before:
handle.ExecWorld(func(tx *world.Tx, e world.Entity) {
    e.(*player.Player).Message("hello")
})

// After:
player.Do(handle, func(tx *world.Tx, p *player.Player) {
    p.Message("hello")
})

Storing references to players and entities

*player.Player and world.Entity values are only valid inside the callback that received them. Code that holds players across time — a minigame roster, an arena's participants — stores the stable reference and re-enters through it:

type Arena struct {
    players []player.Ref // not []*player.Player
}

for _, r := range a.players {
    r.Do(func(tx *world.Tx, p *player.Player) { p.Message("round over") })
}

Refs are built with player.NewRef(h) and world.NewEntityRef[T](h). A one-shot call does not need a ref: player.Do(h, f) works directly on the handle.

Deferred work

The old workarounds for running something right after the current event were the un-awaited nested Exec and the goroutine paid purely to make the wait safe:

// Before — fire-and-forget nested Exec (awaiting it would deadlock):
tx.World().Exec(func(tx *world.Tx) { ... })

// Before — or re-entry from a goroutine:
go func() {
    <-w.Exec(func(tx *world.Tx) { ... })
}()

// After:
tx.Defer(func(tx *world.Tx) { ... })

Defer runs on the same owner immediately after the current callback finishes, ahead of everything else in the world's queue, so it sees the world essentially as the callback left it. Deferred callbacks run FIFO in registration order — not Go defer's LIFO — and each receives a fresh *Tx; the one from the current callback must not be captured. On a player.Context, ctx.Defer also re-resolves the player for that moment.

Typical uses are removing or adding entities found while iterating tx.Entities() (mutating the world mid-iteration is unsafe) and acting after an event's default behaviour has applied, since handlers run before it.

One behavioural difference from the old un-awaited Exec: that went to the back of the world's queue, so other queued work ran in between. Defer jumps the queue. Code that relied on back-of-queue ordering can use w.Do from inside the callback instead.

Situation Use
Right after this callback, same world tx.Defer
Soon, from anywhere w.Do
Later DoAfter
Wherever the entity is by then handle.Do

Iterating players

Server.Accept and Server.Players run the loop body on the player's world owner. Blocking there stalls that world, and calling world.Call* or Task.Wait from the loop body deadlocks. With a non-nil tx, Players yields players in other worlds by blocking on those owners one at a time — keep it out of latency-sensitive code, and note that two worlds whose handlers iterate each other can deadlock.

Players yielded are only valid inside the loop body. To act on them later, collect handles and fan out:

var handles []*world.EntityHandle
for p := range srv.Players(nil) {
    handles = append(handles, p.H())
}
for _, h := range handles {
    player.Do(h, func(tx *world.Tx, p *player.Player) { p.Message("hi") })
}

Handlers and commands

Code inside handlers and commands is already on the owner and keeps using the context it is given. The one rename for player events is ctx.Val() → ctx.Player(), and world operations are available directly on the event context:

func (h MyHandler) HandleBlockBreak(ctx *player.Context, pos cube.Pos, ...) {
    ctx.Player().Message("broken")
    ctx.SetBlock(pos.Side(cube.FaceUp), block.Air{}, nil)
}

For world.Handler implementors: cancellable events still take ctx *world.Context, which embeds the transaction. HandleEntitySpawn, HandleEntityDespawn and HandleClose now take tx *world.Tx — they were never cancellable, and the signature now says so.

For command authors: Runnable.Run's tx is nil when the source is not attached to a world, such as a console source. Commands that use tx must nil-check it; target selectors already fail politely.

Handling failures

Every Do, DoAfter and Defer returns a *world.Task:

task := handle.DoAfter(time.Second, func(tx *world.Tx, e world.Entity) {
    // ...
})
task.OnDone(func(err error) {
    switch {
    case err == nil:                            // ran fine
    case errors.Is(err, world.ErrEntityClosed): // entity despawned or closed before it fired
    case errors.Is(err, world.ErrWorldClosed):  // its world shut down first
    case errors.Is(err, world.ErrTaskPanicked): // the callback panicked (stack in *world.PanicError)
    }
})

Other errors a task can report: world.ErrEntityType (a typed ref's task ran but the entity is no longer that type), world.ErrEntityNotInWorld (a player.Context.Defer fired after the player moved to another world — the player is alive, just not here; follow it with player.Do if that is the intent) and world.ErrTaskCancelled (the task was stopped with Cancel()).

There are three ways to consume a task. OnDone(f) registers a completion hook that always runs on a fresh goroutine, so it is safe to register anywhere — but the hook itself runs off-owner: schedule again rather than touching world state from it. Wait(ctx), Err() and Done() are for code already off the owner, such as background goroutines, shutdown paths and tests; calling Wait from inside an owner callback blocks the owner on itself. Cancel() aborts a task that has not started yet.

Ignoring the task is also fine: a panicking callback is still logged with its stack through the world's logger. The synchronous Call functions re-panic on the waiting goroutine automatically. To escalate a Do task's captured panic manually:

if pe, ok := errors.AsType[*world.PanicError](task.Err()); ok {
    panic(pe.Value) // original stack is in pe.Stack and already logged
}

Clone this wiki locally