A retained-mode 2D scene graph, interaction layer, and render compiler for Ebitengine.
New here? Check out the examples — runnable demos with no external assets required.
Willow is a structured foundation for 2D applications built on Ebitengine. It provides the scene graph, input handling, sprite batching, and rendering pipeline that every non-trivial 2D project needs - packaged as a single, focused library.
It sits between Ebitengine and your game:
Your Game - gameplay, content, logic
willow - scene graph, rendering, interaction
Ebitengine - GPU backend, window, audio, platform
Ebitengine is an excellent, minimal 2D engine for Go - but every project beyond a prototype ends up building the same infrastructure from scratch: transform hierarchies, draw-call batching, hit testing, camera viewports, text layout, sprite atlases.
Willow exists so you don't have to rebuild that foundation every time.
It was created with a specific belief: Go deserves a clean, structured way to build 2D applications. Not a heavy framework that dictates how you work, but a transparent layer that handles the tedious parts and gets out of the way.
Inspired by Starling, Flash display lists, and PixiJS - scene graph architectures that powered millions of 2D applications - adapted for Go's strengths: simplicity, performance, and zero magic.
- Structure without handcuffs. Willow provides hierarchy, transforms, and batching. It does not impose game architecture. Any genre, any pattern, any scale.
- Performance as a contract. Zero heap allocations per frame on the hot path. 10,000 sprites at 120+ FPS on desktop, 60+ FPS on mobile and web. Verified with compiler escape analysis and benchmark suites.
- Wrap Ebitengine, never fight it. Willow uses Ebitengine's draw calls, image types, and threading model directly.
- No genre bias. Willow is for any 2D project - games, tools, visualizations, simulations.
- Minimal public API. Every exported symbol earns its place. Fewer concepts, less to learn, less to break.
go get github.com/phanxgames/willow@latestFor quick setup, call willow.Run(scene, config) and Willow handles the window and game loop. For full control, implement ebiten.Game yourself and call scene.Update() and scene.Draw(screen) directly — both paths are first-class.
package main
import (
"log"
"github.com/phanxgames/willow"
)
func main() {
scene := willow.NewScene()
sprite := willow.NewSprite("hero", willow.TextureRegion{})
sprite.ScaleX = 40
sprite.ScaleY = 40
sprite.Color = willow.Color{R: 0.3, G: 0.7, B: 1, A: 1}
sprite.X = 300
sprite.Y = 220
scene.Root().AddChild(sprite)
if err := willow.Run(scene, willow.RunConfig{
Title: "My Game",
Width: 640,
Height: 480,
}); err != nil {
log.Fatal(err)
}
}Runnable examples are included in the examples/ directory:
go run ./examples/basic # Bouncing colored sprite
go run ./examples/shapes # Rotating polygon hierarchy with parent/child transforms
go run ./examples/interaction # Draggable, clickable rectangles with color toggle
go run ./examples/text # Bitmap font text with colors, alignment, word wrap
go run ./examples/texttf # TTF text with colors, per-line alignment, word wrap, and outline
go run ./examples/tweens # Position, scale, rotation, alpha, and color tweens
go run ./examples/particles # Fountain, campfire, and sparkler particle effects
go run ./examples/shaders # 3x3 grid showcasing all built-in shader filters
go run ./examples/outline # Outline and inline filters on a sprite
go run ./examples/masks # Star polygon, cursor-following, and erase masking
go run ./examples/lighting # Dark dungeon with colored torches and mouse-following lantern
go run ./examples/atlas # TexturePacker atlas loading with named regions and magenta placeholders
go run ./examples/tilemap # Tile map rendering with camera panning
go run ./examples/rope # Draggable endpoints connected by a textured rope
go run ./examples/watermesh # Water surface with per-vertex wave animationFull API documentation is available on pkg.go.dev.
- Scene graph - Parent/child transform inheritance (position, rotation, scale, skew, pivot) with alpha propagation and Pixi-style
ZIndexsibling reordering. - Sprite batching - TexturePacker JSON atlas loading with multi-page, trimmed, and rotated region support. Consecutive draws are grouped automatically into single
DrawImagecalls. - Camera system - Multiple independent viewports with smooth follow, scroll-to animation (45+ easings), bounds clamping, frustum culling, and world/screen coordinate conversion.
- Input and interaction - Hierarchical hit testing with pluggable shapes (rect, circle, polygon). Pointer capture, drag dead zones, multi-touch, and two-finger pinch with rotation. Callbacks per-node or scene-wide.
- Text rendering - Bitmap fonts (BMFont
.fnt) for pixel-perfect rendering, TTF fallback via Ebitenginetext/v2. Alignment, word wrapping, line height overrides, and outlines. - Particle system - CPU-simulated with preallocated pools. Configurable emit rate, lifetime, speed, gravity, and scale/alpha/color interpolation. Optional world-space emission.
- Mesh support -
DrawTriangleswith preallocated vertex and index buffers. High-level helpers for rope meshes, filled polygons, and deformable grids. - Subtree command caching -
SetCacheAsTreecaches all render commands for a container's subtree and replays them with delta transform remapping. Camera panning, parent movement, and alpha changes never invalidate the cache. Animated tiles (same-page UV swaps) are handled automatically via a two-tier source pointer - no invalidation, no API overhead. Manual and auto-invalidation modes. Includes sort-skip optimization when the entire scene is cache hits. - Filters and effects - Composable filter chains via Kage shaders. Built-in: color matrix, blur, outline, pixel-perfect outline, pixel-perfect inline, palette swap. Render-target masking and
CacheAsTexture. - Lighting - Dedicated lighting layer using erase-blend render targets with automatic compositing.
- Animation - Tweening via gween with 45+ easing functions. Convenience wrappers for position, scale, rotation, alpha, and color. Auto-stops on node disposal.
- ECS integration - Optional
EntityStoreinterface to bridge interaction events into your ECS. Ships with a Donburi adapter. - Debug mode - Performance timers, draw call and batch counting, tree depth warnings, and disposed-node assertions via
scene.SetDebugMode(true).
Willow is designed around a zero-allocation-per-frame contract on the hot path:
- Preallocated command buffer reused each frame
- Dirty flag propagation - static subtrees skip transform recomputation entirely
- Custom merge sort with preallocated scratch buffer (no
sort.SliceStableallocations) - Typed callback slices - no
interface{}boxing in event dispatch - Render-texture pooling by power-of-two size buckets
- Value-type
DrawImageOptionsdeclared once, reused per iteration
Subtree command caching (SetCacheAsTree) avoids re-traversing static subtrees entirely. Commands are stored at cache time and replayed with a single matrix multiply per command. Camera movement, parent transforms, and alpha changes are handled via delta remapping. Animated tiles (UV swaps within the same atlas page) are handled automatically via a two-tier source pointer indirection - no invalidation, no API overhead. This is designed to allow batch group of tilemaps with animated tiles (e.g. water) to be performance-optimized by avoiding full subtree invalidation on every frame.
| Scenario (10K sprites) | Time | vs uncached |
|---|---|---|
| Manual cache, camera scrolling | ~39 μs | ~125x faster |
| Manual cache, 100 animated tile UV swaps | ~1.97 ms | ~2.5x faster |
| Auto cache, 1% of children moving | ~4.0 ms | ~1.2x faster |
| No cache (baseline) | ~4.9 ms | — |
The cache is per-container, and will be invalidated if a child within the container moves. It is recommended to separate static content (tilemaps, UI panels) from dynamic content (players, projectiles) into different containers for best results.
Benchmark suite included: go test -bench . -benchmem
- Spatial chunking for CacheAsTree — Grid-based chunk culling so large tilemaps (40K+ tiles) only replay visible cached commands instead of the full set. Build-time spatial indexing with O(visible) replay cost. Designed for camera-panning scenarios where CacheAsTree captures the entire map but only a viewport-sized portion is on screen. See
spec/_archive/cache-as-tree-culling.mdfor the full design. - Dynamic atlas packing — Runtime
Atlas.Add(name, img)for lazy asset loading. Shelf/guillotine packing into existing atlas pages to preserve batching. - UI widget layer (buttons, text input, layout, focus traversal) as a separate companion library (willow-ui)
- Example projects and starter templates
- Comprehensive API documentation and guides
- Tutorials and integration walkthroughs
- Performance profiling across mobile and WebAssembly targets
- Community feedback and API stabilization
- Go 1.24+
- Ebitengine v2.9+
- All Ebitengine-supported platforms: Windows, macOS, Linux, iOS, Android, WebAssembly
Contributions are welcome. Please open an issue to discuss proposed changes before submitting a pull request.
MIT - see LICENSE for details.



