Bunyip a game engine in Go GitHub

Game services

The data and simulation helpers work in headless servers as well as in games. Asset file access and CPU decoding do not open a window; asset loaders that upload textures, fonts or models need a graphics context and must run on its rendering goroutine. The entity component system has its own guide, Entities and systems.

Assets

asset resolves names across loose directories, pack files and any fs.FS such as an embed.FS, in the order given, so a developer's copy overrides the packed or embedded one. Open takes paths; OpenFS takes Dir, PackFile and FSSource sources. One-call loaders turn a name into an engine object: asset.Texture, asset.Atlas (the JSON and the image it names, from the same directory), asset.Font, asset.SDFFont, asset.Sound, asset.Music, asset.Model (with the model's buffers and images resolved through the same files), asset.Tracker, asset.Emitter (a particle.Emitter saved as JSON, with the texture its TextureName asks for), and asset.Scene and asset.Prefab for the ECS documents described in the ECS guide. A Loader decodes on worker goroutines behind a progress counter, for loading screens, a Watcher reports loose files that changed on disk, and a Reloader swaps those files' textures and shaders in place while the game runs. bunyip-pack builds pack files.

Every one-call loader and NewLoader accepts a standard fs.FS directly: asset.Image(embeddedAssets, "sprites/hero.png") needs no asset wrapper. Use asset.Open or OpenFS when combining sources. Their returned FS supports fs.ReadFile, fs.ReadDir, fs.Sub and fs.WalkDir; directories merge while earlier files hide lower-priority names and their children. Open and ReadFile require valid io/fs paths and return standard filesystem errors. Legacy Read also cleans dot components and backslashes. Only missing names fall through to the next source; permission errors are returned. Watchers and reloaders still use *asset.FS to locate loose files.

// Loose files first so a developer's copy wins, then the shipped pack.
fs, err := asset.Open("assets", "game.pak")
if err != nil {
	return err
}
g.fs = fs
if g.hero, err = asset.Texture(ctx.Gfx, fs, "sprites/hero.png", gfx.TextureOptions{}); err != nil {
	return err
}
if g.font, err = asset.Font(ctx.Gfx, fs, "fonts/ui.ttf", 16, gfx.FontOptions{}); err != nil {
	return err
}

OpenFS takes sources instead of paths, so an embed.FS can sit under a loose directory:

//go:embed assets
var embedded embed.FS

embeddedAssets, err := fs.Sub(embedded, "assets")
if err != nil {
	return err
}
files, err := asset.OpenFS(asset.Dir("assets"), asset.FSSource(embeddedAssets))

A Loader decodes CPU data in the background while a loading screen draws. Handle.Ready polls, Get blocks, and Progress drives the bar:

loader := asset.NewLoader(fs, 0) // 0 workers means one per core
defer loader.Close()
level := loader.Load("levels/1.json", parseLevel)
...
done, total := loader.Progress()
g.bar = float32(done) / float32(total)
if level.Ready() {
	g.level, err = level.Get()
}

Submission may block when the 256-entry job queue fills. Close stops submission and waits for accepted work and the workers to finish, so the asset FS can close next. Later submissions return ready handles with fs.ErrClosed. A blocked reader or decoder must return before shutdown finishes; a decoder must not close its own loader. Wait waits for work without closing the loader; stop submitting before calling it. Upload decoded GPU resources on the rendering goroutine after a handle is ready.

Hot reload

A Reloader keeps what a game loaded in step with the files it came from, so a texture repainted in an art tool or a shader recompiled with bunyip-shader appears the moment it is saved. Load through it instead of the package's loaders and call Reload once a frame:

func (g *game) Init(ctx *engine.Context) error {
	g.rel = asset.NewReloader(ctx.Gfx, g.fs, 0) // 0 means poll twice a second
	var err error
	if g.hero, err = g.rel.Texture("sprites/hero.png", gfx.TextureOptions{}); err != nil {
		return err
	}
	g.water, err = g.rel.Shader("shaders/water.spv")
	return err
}

func (g *game) Update(ctx *engine.Context) error {
	names, err := g.rel.Reload()
	if err != nil {
		ctx.Log.Warn("reload failed", "err", err) // the old asset stays
	}
	for _, n := range names {
		ctx.Log.Info("reloaded", "asset", n)
	}
	...
}

Everything it loads keeps the pointer it handed back, which is what makes a material reload work without bookkeeping: the texture's image is swapped in place, so every gfx.Material, sprite and shader image slot that names it draws the new pixels, even when the new image is a different size. A shader's pipelines are rebuilt behind gfx.Shader.Reload, keeping its images and uniforms, so every draw through it runs the new program. Both cost no GPU wait inside a frame: the image and the pipelines the old frames may still be drawing from go on the retire ring.

Reloader.Watch covers anything the package has no loader for, such as a level or a table of tuning values:

g.rel.Watch("levels/1.json", func(data []byte) error {
	lv, err := parseLevel(data)
	if err != nil {
		return err
	}
	g.level = lv
	return nil
})

Only loose files change, so a name that resolves into a pack file or an embed.FS is loaded once and never watched: a shipped game pays for the polling goroutine and nothing else, and the same code runs in both builds. A file that fails to decode keeps the asset the game already has and reports the error, so a half-written save does not take the game down with it, and the next write is tried again. Watcher is the layer under all this for a game that would rather reload by hand. It resolves each name to its file once and then stats that file per poll, plus the directory in each overlaying source where a new copy would appear, so a thousand watched files cost about what a thousand os.Stat calls do. The poll runs outside the lock Changed takes, so the call a game makes every frame never waits for it. Changed names a file once however many times it changed since the last call, so a save that truncates, writes and then sets the time reloads once.

Models and environments are not reloaded. Swapping a glTF file's contents gives back different meshes, a different skeleton and different animation clips, and every gfx.AnimPlayer, mesh pointer and node index the game holds refers to the old ones. A gfx.Environment is a panorama prefiltered into a cube map, and a gfx.ReflectionProbe bakes and owns one of its own, so replacing the image behind one means rebuilding every level of that cube. A game that wants either loads it again and rebinds what pointed at the old one.

The texture pipeline

bunyip-tex compresses a PNG or JPEG into a KTX2 file holding BC blocks and the whole mip chain, so a texture takes a quarter to an eighth of the GPU memory it would as RGBA and nothing is compressed or downsampled while the game runs:

bunyip-tex -format bc7 -outdir build/textures art/textures/*.png
bunyip-pack -o game.pak build

asset.Texture reads a name ending in .ktx2 through gfx.NewCompressedTexture and anything else through the image decoders, so a game changes the extension and nothing else, and Reloader.Texture watches either kind. Packs store .ktx2 files as they are, since block data does not deflate. The 2D graphics guide has the formats and what each is for, and gfx/ktx2 is the package under both the tool and the loader for a game with its own pipeline.

Saves and settings

save writes JSON documents into the platform's data directory (Application Support, XDG data, AppData) and replaces them through a synced temporary file and rename, so an interrupted write does not leave partial JSON at the target. Rename atomicity follows the host filesystem; the parent directory is not synced, so this is not a power-loss durability guarantee. Load reads a document over defaults, so new settings fields get sensible values when a file predates them. Whole ECS worlds are saved through ecs.World.Save, described in the ECS guide.

type settings struct {
	Volume     float32
	Fullscreen bool
}

store, err := save.Open("my-game") // Application Support, AppData or XDG
if err != nil {
	return err
}
s, err := store.Load("settings", settings{Volume: 0.8}) // defaults for a missing file
if err != nil {
	return err
}
s.Fullscreen = true
store.Write("settings", s)
names, _ := store.List() // the save slots, for a load menu

Write returns once the file is synced to the drive. A sync waits for the storage device, which takes several milliseconds even for a small file (on macOS it flushes the drive's cache), so a Write from Update can miss a frame. To autosave from the game loop, call WriteAsync: it encodes the value before it returns, so the game can keep changing it, and writes, syncs and renames on a background goroutine. The returned channel receives nil once the file is in place, or the error. Writes to one name land in the order they were made, and Read, Load, Exists and Delete for that name wait for them. Call Flush before the game exits so pending writes reach the disk.

// In Update: costs the encoding, not the disk.
g.saving = store.WriteAsync("autosave", g.state)

// On a later frame. A nil channel never receives, so this is a no-op
// once the result has been read.
select {
case err := <-g.saving:
	g.saving = nil
	if err != nil {
		g.warn("autosave failed:", err)
	}
default: // still writing, or nothing pending
}

// In Shutdown.
store.Flush()

save.OpenAt(dir) takes any directory, which is what tests use. BUNYIP_DATA_DIR overrides the base data directory and the app name is appended to it. Save names omit .json and must be nonempty leaf names without slashes or ./.. components. Store.Load copies defaults through JSON even when the file is missing, so mutable maps and slices are independent of the defaults. Defaults that cannot be round-tripped through JSON return an error.

Translation

locale holds a Table of messages per language, loaded from JSON a translator edits, with {name} placeholders and plural forms chosen by each language's rules. Plural knows the common languages, from English's two forms to Arabic's six. A Bundle falls back through languages for keys a translation lacks, Missing lists what a translator still has to do, and For gives a Translator whose T and N the game calls.

Supply fallbacks explicitly; English is not installed automatically. The lookup order is the requested language, its base language, then the configured fallbacks. A missing key becomes [key], and a placeholder without an argument remains as written. Include other in each plural entry. Build tables before concurrent reads and synchronize any later changes; bundles and tables have no internal locks.

b := locale.NewBundle("en") // English last, for keys another language lacks
for _, lang := range []string{"en", "ru"} {
	data, err := fs.Read("lang/" + lang + ".json")
	if err != nil {
		return err
	}
	if err := b.Load(lang, data); err != nil {
		return err
	}
}

t := b.For("ru")
t.T("menu.play")            // "Играть", or English if Russian lacks the key
t.T("greet", "who", "Ada")  // fills the {who} placeholder
t.N("inv.arrows", 3)        // the plural form Russian uses for 3
b.Missing("ru", "en")       // keys the translator still has to do

Random numbers

rng is a seedable PCG32 generator. The same seed gives the same sequence on every platform; Fork gives a subsystem its own stream, so adding a call in one place never changes what another produces; State and Restore put the generator in a save file. Pick, Shuffle, Roll and WeightedIndex cover the usual game needs.

r := rng.New(g.seed)
loot := r.Fork() // its own stream: rolling here never moves the other one

damage := r.Roll(2, 6) + 1 // 2d6+1
if loot.Chance(0.1) {
	drop := loot.Pick(g.rareItems)
	_ = drop
}
i := rng.WeightedIndex(loot, []float32{5, 3, 1}) // common, uncommon, rare
loot.Shuffle(g.deck)

state, inc := r.State() // into the save file; r.Restore(state, inc) on load

Timers, sequences and tweens

timer schedules callbacks on game time (After, Every) and offers a pollable Countdown. Because time is the game's own, a paused game stops its timers by not calling Update, and a replay runs them identically.

Use finite, nonnegative time steps. Timers due together fire in registration order. Repeating timers catch up every elapsed interval, so a large update may call the same function several times. Callbacks may schedule or cancel timers, but must not recursively call Update.

func (g *game) Update(ctx *engine.Context) error {
	if !g.paused {
		g.timers.Update(ctx.Delta) // g.timers is a timer.Scheduler
		if g.round.Update(ctx.Delta) {
			g.endRound() // the Countdown reached zero on this update
		}
	}
	return nil
}

g.timers.After(1.5, func() { g.door.Open() })
spawns := g.timers.Every(3, g.spawnWave)
g.timers.Cancel(spawns)
g.round.Start(90) // g.round is a timer.Countdown; Running says time remains

To write a cutscene, a turn's animation or a boss pattern as a list of steps instead of a state machine, use timer.Sequence. The steps are Do something, Wait a second, wait Until a condition holds, Run a function each update until it reports it is done, and Loop for patrols. Skip jumps to the end for a player who presses through.

g.cutscene = timer.NewSequence().
	Do(func() { g.camera.PanTo(g.door) }).
	Until(func() bool { return g.camera.Arrived() }).
	Wait(0.5).
	Do(g.door.Open).
	Run(func(dt float32) bool { return g.hero.WalkTo(g.door, dt) })

// From Update; it reports whether the sequence has finished.
g.cutscene.Update(float32(ctx.Delta))
if ctx.Input.KeyPressed(input.KeySpace) {
	g.cutscene.Skip()
}

tween eases a value from one number to another with the usual curves, repeats and yo-yos; Sequence chains tweens, and Of (with NewVec2 and NewVec3) tweens vectors, colours or any value that has a blend function.

Easing curves such as OutBack and OutElastic may overshoot the endpoints; Progress is not always between zero and one. Scalar and generic tweens both support YoYo, reversing their endpoints on each repeat. OnDone runs once when the complete sequence of repeats ends and returns the same tween for chaining, including its generic value type.

g.menuX = tween.New(-300, 40, 0.4, tween.OutQuad) // slide the panel in
g.pulse = tween.New(1, 1.2, 0.3, tween.InOutSine)
g.pulse.Repeat, g.pulse.YoYo = -1, true // forever, back and forth
g.fade = tween.NewOf(gfx.Transparent, gfx.White, 0.5, tween.OutQuad, gfx.Color.Lerp).
	OnDone(func() { g.ready = true })

// From Update.
x := g.menuX.Update(float32(ctx.Delta))
tint := g.fade.Update(float32(ctx.Delta))
if g.menuX.Done() {
	g.ready = true
}

Grids

grid is for tile games: a generic Grid, AStar with four- or eight-way movement and per-step costs, Dijkstra maps with Downhill for chasing and fleeing, Bresenham Line, shadowcasting FOV and FloodFill.

walls := grid.New[bool](64, 48)
walls.Set(10, 10, true)
cost := func(from, to grid.Point) float32 {
	if walls.At(to.X, to.Y) {
		return grid.Blocked
	}
	return 1
}

// Every traversable step costs at least 1; true enables eight-way movement.
path := grid.AStarWithMinCost(64, 48, g.player, g.exit, true, cost, 1)

// One Dijkstra map moves the whole crowd: every monster steps downhill.
dist := grid.Dijkstra(64, 48, []grid.Point{g.player}, true, cost)
for i, m := range g.monsters {
	if next, ok := grid.Downhill(dist, m, true); ok {
		g.monsters[i] = next
	}
}

// Cells off the map count as opaque, so sight stops at the edge.
opaque := func(p grid.Point) bool { return !walls.In(p.X, p.Y) || walls.At(p.X, p.Y) }
seen := map[grid.Point]bool{}
grid.FOV(g.player, 9, opaque, func(p grid.Point) { seen[p] = true })

The algorithms take a cost or passability function over points, not a Grid, so they work against whatever the game keeps its map in. Costs may be zero or fractional, but must not be NaN. The callback defines diagonal costs and whether cutting across a blocked corner is allowed; the algorithms add neither restriction nor a diagonal multiplier. Without a minimum step cost, AStar is an uninformed search: it uses a zero heuristic, so it finds cheapest paths without needing a lower bound on step costs, but it expands cells in every direction as Dijkstra does. On an open 256 by 256 map that is several times slower than a guided search. For speed, call AStarWithMinCost with the smallest cost any step can have: pass 1 for unit-cost movement, or 0.1 if every traversable step costs at least 0.1. The bound applies to diagonal steps too. The search scales Manhattan distance for four-way movement or Chebyshev distance for eight-way movement by that bound. Where several paths cost the same, the two searches may return different ones.

A positive finite bound must never exceed any traversable step's cost; overstating it can produce a more expensive path. Pass zero when the minimum is unknown or zero-cost moves exist. Zero, negative and nonfinite bounds all use the safe zero heuristic.

Dijkstra computes costs from its sources to every cell. With directed costs, reverse the callback arguments to compute costs toward a target. Downhill only chooses a neighboring cell with a lower stored value; it does not check passability, edge costs or blocked corners. It can stop on a zero-cost plateau and does not guarantee a cheapest route on weighted or directed maps. The chasing example above uses symmetric unit-cost movement; use the cost-aware path search for other rules.

AStar, Dijkstra and FOV borrow pooled scratch buffers. First use, growth and pool eviction can allocate. To retain scratch across frames, keep a Pathfinder for the map and a Vision for the viewer and call their methods. They avoid steady-state allocations once their scratch and caller-owned result buffers have sufficient capacity. Pathfinder.AStar and Pathfinder.AStarWithMinCost append the path to a slice the game owns and report whether there was one. To guide every Pathfinder.AStar call on a map, set Pathfinder.MinCost once when the map is made; its zero value leaves the search uninformed. Pathfinder.DijkstraInto fills a map the game already has, and Vision.FOV reuses the scratch space a cast needs.

// Made once, with the map, and kept.
g.pf = grid.NewPathfinder(64, 48)
g.pf.MinCost = 1 // every step costs at least 1
g.dist = grid.New[float32](64, 48)

// Each frame, searching into the game's own buffers.
if path, ok := g.pf.AStar(g.path[:0], g.player, g.exit, true, cost); ok {
	g.path = path
}
g.pf.DijkstraInto(g.dist, []grid.Point{g.player}, true, cost)
g.sight.FOV(g.player, 9, opaque, func(p grid.Point) { g.seen[p] = true })

Resize points a Pathfinder at a map of another size when the level changes. Both types hold scratch space rather than results, so give each goroutine its own.

Networking

network moves typed messages between game instances: ordered over TCP for turn-based play, lobbies and chat, and over UDP for real-time state. A Registry names the message types both ends agree on; events arrive through Poll once per frame, and SetOnActivity can wake a sleeping turn-based game.

Closing a local TCP connection or server cancels publication blocked by a full event queue. Already queued events remain available to Poll, but pending messages or disconnect notifications may be dropped during local shutdown. A remote disconnect is queued after its preceding messages as usual.

SetOnActivity may be changed while messages arrive, including from inside its callback. Server registration updates existing and future connections. If events are pending, registration invokes the new callback before returning; later notifications run on network goroutines. This also wakes for a client's initial Connected event. Callbacks run outside locks and should signal the game loop, for example with ctx.Wake. Keep them short; drain pending events before registering again inside a callback to avoid recursion. A captured callback may finish after replacement or removal. Nil disables future captures. UDP uses the same registration behavior; its notifications can cover multiple events. TCP event queues hold 1024 events and apply backpressure when full. TCP and TLS Send use a five-second DefaultSendTimeout, covering writer acquisition, a pending TLS handshake, and transport writes. SendContext(ctx, msg) accepts your own cancellation and deadline; context.Background() explicitly permits an indefinite wait. Encoding runs synchronously, so a custom marshaler that blocks cannot be interrupted. Sends still block the caller; use a short context budget or send from your own worker when the game loop must remain responsive.

Cancellation while waiting for another sender leaves the connection usable. An interrupted TLS handshake or failed write closes it to avoid continuing after a partial frame or an unusable TLS stream. Send errors preserve errors.Is checks for context.Canceled and context.DeadlineExceeded. A completed send means the transport accepted the message, not that the peer processed it.

Each message goes to the transport as one frame, its length header and payload in a single write. Send and Broadcast enforce their budget with the connection's write deadline, so a send allocates nothing beyond the encoding.

Broadcast encodes the message once and sends the same frame sequentially to every selected peer, with one shared five-second budget. BroadcastContext uses one caller-supplied budget. Both return a map[*network.Conn]error containing only failed peers, including peers not reached before cancellation; nil means every selected peer accepted its message. Connection iteration order is unspecified. Handle individual errors directly:

for peer, err := range server.Broadcast(Chat{From: "server", Text: "Ready"}) {
	fmt.Printf("peer %d: %v\n", peer.ID, err)
}

Complete registry registration before opening connections and leave it unchanged while network goroutines use it. TCP and UDP connections decode each message from a read buffer they reuse, so a binary message's UnmarshalBinary must copy any bytes it keeps, as encoding.BinaryUnmarshaler requires.

Messages are plain structs. Both ends build the same registry in the same order:

type Chat struct{ From, Text string }
type Move struct{ X, Y int }

reg := network.NewRegistry().Register(Chat{}, Move{})

The server listens and drains its events each update:

server, err := network.Listen(":7777", reg)
if err != nil {
	return err
}
server.SetOnActivity(ctx.Wake) // turn-based: wake the loop when a message lands

for _, ev := range server.Poll() {
	switch ev.Kind {
	case network.Connected:
		g.log("joined:", ev.Conn.Addr())
	case network.Disconnected:
		g.drop(ev.Conn)
	case network.Message:
		if m, ok := ev.Msg.(*Move); ok {
			server.Broadcast(m, ev.Conn) // to everyone but the sender
		}
	}
}

The client works the same way, with Send on the connection it embeds:

client, err := network.Dial("gameserver:7777", reg, 5*time.Second)
if err != nil {
	return err
}
defer client.Close()
client.Send(Chat{From: g.name, Text: "hello"})

for _, ev := range client.Poll() {
	if m, ok := ev.Msg.(*Chat); ok {
		g.lines = append(g.lines, m.From+": "+m.Text)
	}
}

ListenTLS and DialTLS encrypt TCP. SelfSignedConfig makes a certificate for a LAN game together with a client configuration pinned to it, and Fingerprint with PinnedConfig lets a player on another machine pin the same certificate.

serverCfg, clientCfg, err := network.SelfSignedConfig("localhost", "192.168.1.20")
if err != nil {
	return err
}
server, err := network.ListenTLS(":7777", reg, serverCfg)
...
// On the host's own machine clientCfg already trusts it.
client, err := network.DialTLS(addr, reg, clientCfg, 5*time.Second)

// Elsewhere: the host reads out this string and the player pins it.
print := network.Fingerprint(serverCfg)
client, err = network.DialTLS(addr, reg, network.PinnedConfig(print), 5*time.Second)

A UDP Peer has two ways to send. Send sends a packet that may be lost, for state that is replaced every frame. SendReliable resends until the packet is acknowledged and delivers in order, for anything that must arrive. Every packet acknowledges what came the other way, and Stats reports a link's round trip, loss and pending count. Peers exchange hello and keep-alive packets, so a Peer raises Connected and Disconnected for UDP addresses (after SetTimeout of silence, a goodbye, or a restart), and Peers lists who is there. After a goodbye in either direction, packets the other side sent before the goodbye are ignored, so the address stays disconnected until it starts a new session or this side sends to it again.

peer, err := network.ListenUDP(":7778", reg)
if err != nil {
	return err
}
defer peer.Close()
peer.SetTimeout(5 * time.Second)

host, err := network.Resolve("gameserver:7778")
peer.Send(host, Move{X: g.x, Y: g.y})    // every frame; a lost one does not matter
peer.SendReliable(host, Chat{Text: "gg"}) // resent until it arrives, in order

if s, ok := peer.Stats(host); ok {
	g.ping = s.RTT // with s.Loss and s.Pending, for the netgraph
}

The remaining helpers are the standard techniques of real-time netcode. An Interpolator draws other players a little behind the newest snapshot so they move smoothly whatever the packet timing. A Predictor applies the local player's inputs at once and reconciles with the server's state by replaying the inputs the server has not yet seen. A History lets a server rewind targets to where a shooter saw them. A Clock estimates the server's time from pings. EncodeDelta sends only the fields of a snapshot struct that changed since a baseline, and only the changed elements of an array field, so a struct of arrays with one entry per entity sends the entities that moved. SnapshotBuffer picks each client's baseline from what it last acknowledged, with SnapshotReceiver on the other end. To encode every client's snapshot into one buffer without allocating, call SnapshotBuffer.AppendEncode, or AppendDelta for a single delta. A server sending 200 entities as arrays of positions and angles, with a tenth of them moving each tick and acknowledgements three ticks old, produces deltas of about 800 bytes, which fit one UDP datagram (MaxDatagram). The delta encoding may change between Bunyip versions before 1.0, so the server and its clients must run the same version. Interest chooses which entities are near enough to a viewer to be worth sending, with hysteresis at the edge so nothing flickers in and out. The two slices Interest.End returns belong to the Interest and are refilled by the next End, so send from them in the same frame and copy anything that has to outlive it.

Prediction takes the most setting up. Step must be the same function the server runs, so the replay lands where the server did:

step := func(s playerState, in playerInput) playerState { return s.move(in) }
g.pred = network.NewPredictor(playerState{}, step)

// Each update: apply locally at once and send the input with its sequence.
in := playerInput{Dx: axis, Jump: jump}
seq := g.pred.Apply(in)
peer.Send(host, inputMsg{Seq: seq, In: in})
g.drawAt = g.pred.State() // already moved, no wait for the server

// When the server's state arrives, rewind and replay what it has not seen.
g.pred.Reconcile(m.Ack, m.State)

Other players go through an Interpolator instead, drawn a little behind the newest snapshot:

g.remote.Delay = 0.1        // two or three send intervals
g.remote.Add(m.Time, m.Pos) // g.remote is a network.Interpolator[lin.Vec2]

// At subtracts Delay itself; the time is the server's, from a Clock.
pos, ok := g.remote.At(g.clock.ServerTime(ctx.Time), lin.Vec2.Lerp)