agents/openai.yaml
interface: display_name: "Ebitengine Game" short_description: "Build, verify and ship 2D games in Go with Ebitengine" default_prompt: "Use $ebitengine-game to build or fix this Ebitengine game in Go."
gamedev-skills/awesome-gamedev-agent-skills · GitHub
Create, improve, debug and package playable 2D games in Go with Ebitengine (github.com/hajimehoshi/ebiten/v2). Use for Ebitengine game requests: game design translated into a complete loop, input, rendering, text, audio, scenes, gameplay tests and desktop, browser or mobile builds. Also use when fixing these systems in an existing Ebitengine game. Do not apply to unrelated Go software or engine-internal patches merely because they depend on Ebitengine.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill ebitengine-game설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
agents/openai.yamlinterface: display_name: "Ebitengine Game" short_description: "Build, verify and ship 2D games in Go with Ebitengine" default_prompt: "Use $ebitengine-game to build or fix this Ebitengine game in Go."
assets/ci/github-actions.yml# Copy to .github/workflows/ci.yml in an Ebitengine project.
# Every push / pull request: vet, unit tests, the skill's checker (if the
# script is vendored at tools/check_ebitengine.py), a headless smoke run that
# captures a frame, and cross-compiles. Tags v*: release archives for
# Windows, macOS (universal .app, unsigned), Linux and the browser.
#
# Assumes the starter's flags (-demo, -shot FILE, -shot-tick N); adapt the
# smoke step if your game differs. Replace "mygame" and the bundle ID.
name: ci
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
permissions:
contents: read
env:
GAME: mygame
CGO_ENABLED: "0"
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Runtime libraries for the smoke run
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends xvfb libgl1 libglx-mesa0 libgl1-mesa-dri libasound2t64
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- name: Tests (a package importing Ebitengine may initialise graphics)
run: |
printf 'pcm.!default { type null }\n' > "$RUNNER_TEMP/alsa-null.conf"
ALSA_CONFIG_PATH="$RUNNER_TEMP/alsa-null.conf" xvfb-run -a go test ./...
- name: Ebitengine checker
run: |
if [ -f tools/check_ebitengine.py ]; then python3 tools/check_ebitengine.py .; fi
- name: Headless smoke run (scripted input, frame capture, no sound card)
run: |
printf 'pcm.!default { type null }\n' > "$RUNNER_TEMP/alsa-null.conf"
go build -buildvcs=false -o smoke .
ALSA_CONFIG_PATH="$RUNNER_TEMP/alsa-null.conf" timeout 60 xvfb-run -a -s "-screen 0 1280x720x24" \
./smoke -demo -shot shot.png -shot-tick 240
test -s shot.png
- uses: actions/upload-artifact@v4
with:
name: smoke-frame
path: shot.png
- name: Cross-compile every target
run: |
GOOS=windows GOARCH=amd64 go build -buildvcs=false -o /dev/null .
GOOS=darwin GOARCH=amd64 go build -buildvcs=false -o /dev/null .
GOOS=darwin GOARCH=arm64 go build -buildvcs=false -o /dev/null .
GOOS=linux GOARCH=amd64 go build -buildvcs=false -o /dev/null .
GOOS=js GOARCH=wasm go build -buildvcs=false -o /dev/null .
release:
permissions:
contents: write # creating releases on tags
if: startsWith(github.ref, 'refs/tags/v')
needs: test
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: |
set -eu
V="${GITHUB_REF_NAME#v}"
LD="-s -w -X main.version=$V" # declare `var version = "dev"` in package main
mkdir -p dist/win dist/linux dist/mac dist/web
# Include the bundled font notice and project-supplied asset notices.
for target in win linux mac web; do cp -R licenses "dist/$target/"; done
# Windows: no console window. Add Steam-specific workarounds only
# when that distribution and the engine version require them.
GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath \
-ldflags="$LD -H=windowsgui" -o "dist/win/$GAME.exe" .
GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags="$LD" -o "dist/linux/$GAME" .
GOOS=darwin GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags="$LD" -o "dist/mac/${GAME}_amd64" .
GOOS=darwin GOARCH=arm64 go build -buildvcs=false -trimpath -ldflags="$LD" -o "dist/mac/${GAME}_arm64" .
GOOS=js GOARCH=wasm go build -buildvcs=false -trimpath -ldflags="$LD" -o dist/web/game.wasm .
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/web/
cp web/index.html dist/web/ # your page that loads wasm_exec.js and game.wasm
- name: macOS app bundle (unsigned; sign and notarise on a Mac)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends llvm
V="${GITHUB_REF_NAME#v}"
# Apple bundle versions are numeric; the binary keeps the full tag.
APP_VERSION="${V%%[-+]*}"
python3 tools/package_macos_app.py --name "$GAME" --bundle-id "com.example.$GAME" \
--version "$APP_VERSION" --build "$GITHUB_RUN_NUMBER" \
--amd64 "dist/mac/${GAME}_amd64" --arm64 "dist/mac/${GAME}_arm64" --out dist/mac
- name: Archives
run: |
set -eu
V="${GITHUB_REF_NAME#v}"
(cd dist/win && zip -r "../${GAME}_${V}_windows_amd64.zip" .)
(cd dist/linux && tar czf "../${GAME}_${V}_linux_amd64.tar.gz" .)
(cd dist/mac && zip -r -y "../${GAME}_${V}_macos_unsigned.zip" "$GAME.app" licenses)
(cd dist/web && zip -r "../${GAME}_${V}_web.zip" .) # index.html at the top level, as itch.io expects
- uses: softprops/action-gh-release@v2
with:
files: |
dist/*.zip
dist/*.tar.gz
assets/starter/assets.gopackage main
import (
"bytes"
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"golang.org/x/image/font/gofont/goregular"
)
var (
colBG = color.RGBA{0x1b, 0x1e, 0x2b, 0xff}
colTile = color.RGBA{0x4a, 0x55, 0x6e, 0xff}
colEdge = color.RGBA{0x8a, 0x97, 0xb5, 0xff}
colPlayer = color.RGBA{0xf2, 0xc1, 0x4e, 0xff}
colCoin = color.RGBA{0x7c, 0xe0, 0xc3, 0xff}
colText = color.RGBA{0xee, 0xee, 0xf2, 0xff}
)
// Assets are created once, before the game loop, and reused every frame.
// Creating images or font sources inside Update/Draw is a common slowdown.
type Assets struct {
Title, Body *text.GoTextFace
Tile *ebiten.Image
Player *ebiten.Image
Coin *ebiten.Image
}
func LoadAssets() (*Assets, error) {
// Real games embed their own font file with //go:embed. goregular is a
// WGL4 font bundled with golang.org/x/image; use a CJK-capable font
// for Japanese/Chinese/Korean text.
src, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF))
if err != nil {
return nil, err
}
a := &Assets{
Title: &text.GoTextFace{Source: src, Size: 40},
Body: &text.GoTextFace{Source: src, Size: 16},
}
// Placeholder art drawn once into small images. Replace with sprites
// decoded from embedded PNGs (see references/rendering.md).
a.Tile = ebiten.NewImage(16, 16)
a.Tile.Fill(colTile)
vector.FillRect(a.Tile, 0, 0, 16, 2, colEdge, false)
a.Player = ebiten.NewImage(12, 14)
vector.FillRect(a.Player, 0, 0, 12, 14, colPlayer, false)
vector.FillRect(a.Player, 7, 3, 2, 3, colBG, false)
a.Coin = ebiten.NewImage(8, 8)
vector.FillCircle(a.Coin, 4, 4, 4, colCoin, true)
return a, nil
}
assets/starter/game.gopackage main
import (
"fmt"
"image"
"image/png"
"os"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
// Scene is one screen of the game. Update changes state; Draw only reads it.
type Scene interface {
Update(g *Game) error
Draw(screen *ebiten.Image)
}
type Options struct {
ShotPath string
ShotTick int
Demo bool
}
// Game implements ebiten.Game and owns what every scene shares.
type Game struct {
opts Options
assets *Assets
sound *Sound
input *Input
scene, next Scene
tick int // number of Update calls so far: the game's clock
shotDone bool
shotErr error
}
func NewGame(opts Options) (*Game, error) {
a, err := LoadAssets()
if err != nil {
return nil, err
}
g := &Game{opts: opts, assets: a, sound: NewSound(), input: NewInput(opts.Demo)}
g.scene = newTitleScene(g)
return g, nil
}
// SetScene switches scenes at the start of the next tick, never mid-frame.
func (g *Game) SetScene(s Scene) { g.next = s }
func (g *Game) Update() error {
if g.shotDone {
if g.shotErr != nil {
return g.shotErr
}
return ebiten.Termination // ends RunGame without an error
}
if g.next != nil {
g.scene, g.next = g.next, nil
}
g.tick++
g.input.Update(g.tick)
alt := ebiten.IsKeyPressed(ebiten.KeyAltLeft) || ebiten.IsKeyPressed(ebiten.KeyAltRight)
if inpututil.IsKeyJustPressed(ebiten.KeyF11) || (alt && inpututil.IsKeyJustPressed(ebiten.KeyEnter)) {
ebiten.SetFullscreen(!ebiten.IsFullscreen())
}
return g.scene.Update(g)
}
func (g *Game) Draw(screen *ebiten.Image) {
g.scene.Draw(screen)
if g.opts.ShotPath != "" && !g.shotDone && g.tick >= g.opts.ShotTick {
if err := savePNG(screen, g.opts.ShotPath); err != nil {
g.shotErr = fmt.Errorf("save screenshot: %w", err)
}
g.shotDone = true // Update returns Termination on the next tick
}
}
// Layout returns a fixed logical size, so game code never deals with window
// size; Ebitengine letterboxes and scales the screen.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenW, screenH
}
// savePNG reads the screen back from the GPU. It is slow; use it for checks,
// not every frame.
func savePNG(screen *ebiten.Image, path string) error {
b := screen.Bounds()
img := image.NewRGBA(b)
screen.ReadPixels(img.Pix) // premultiplied RGBA; the screen is opaque here
f, err := os.Create(path)
if err != nil {
return err
}
if err := png.Encode(f, img); err != nil {
f.Close()
return err
}
return f.Close()
}
assets/starter/input.gopackage main
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
// Actions is what the game reacts to. Devices are mapped onto actions in one
// place, so scenes never test raw keys.
type Actions struct {
Left, Right bool
Jump bool // held
JumpPressed bool // pressed this tick
Confirm bool // pressed this tick
Pause bool // pressed this tick
}
type Input struct {
Now Actions
demo bool
pads []ebiten.GamepadID // reused buffers: no allocation per tick
touches []ebiten.TouchID
}
func NewInput(demo bool) *Input { return &Input{demo: demo} }
// Update must be called from Game.Update: "just pressed" state is per tick.
func (in *Input) Update(tick int) {
if in.demo {
in.Now = demoActions(tick)
return
}
var a Actions
key := ebiten.IsKeyPressed
just := inpututil.IsKeyJustPressed
a.Left = key(ebiten.KeyArrowLeft) || key(ebiten.KeyA)
a.Right = key(ebiten.KeyArrowRight) || key(ebiten.KeyD)
a.Jump = key(ebiten.KeySpace) || key(ebiten.KeyZ) || key(ebiten.KeyArrowUp) || key(ebiten.KeyW)
a.JumpPressed = just(ebiten.KeySpace) || just(ebiten.KeyZ) || just(ebiten.KeyArrowUp) || just(ebiten.KeyW)
a.Confirm = just(ebiten.KeyEnter) || just(ebiten.KeySpace)
a.Pause = just(ebiten.KeyEscape) || just(ebiten.KeyP)
in.pads = ebiten.AppendGamepadIDs(in.pads[:0])
for _, id := range in.pads {
if !ebiten.IsStandardGamepadLayoutAvailable(id) {
continue // unknown mapping: offer rebinding instead of guessing
}
btn := func(b ebiten.StandardGamepadButton) bool { return ebiten.IsStandardGamepadButtonPressed(id, b) }
jbtn := func(b ebiten.StandardGamepadButton) bool { return inpututil.IsStandardGamepadButtonJustPressed(id, b) }
stick := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickHorizontal)
a.Left = a.Left || btn(ebiten.StandardGamepadButtonLeftLeft) || stick < -0.4
a.Right = a.Right || btn(ebiten.StandardGamepadButtonLeftRight) || stick > 0.4
a.Jump = a.Jump || btn(ebiten.StandardGamepadButtonRightBottom)
a.JumpPressed = a.JumpPressed || jbtn(ebiten.StandardGamepadButtonRightBottom)
a.Confirm = a.Confirm || jbtn(ebiten.StandardGamepadButtonRightBottom) || jbtn(ebiten.StandardGamepadButtonCenterRight)
a.Pause = a.Pause || jbtn(ebiten.StandardGamepadButtonCenterRight)
}
// Touch: left third = left, right third = right, middle = jump/confirm.
in.touches = ebiten.AppendTouchIDs(in.touches[:0])
for _, id := range in.touches {
x, _ := ebiten.TouchPosition(id)
switch {
case x < screenW/3:
a.Left = true
case x > screenW*2/3:
a.Right = true
default:
a.Jump = true
}
}
in.touches = inpututil.AppendJustPressedTouchIDs(in.touches[:0])
for _, id := range in.touches {
a.Confirm = true // any new touch can start the title screen
if x, _ := ebiten.TouchPosition(id); x >= screenW/3 && x <= screenW*2/3 {
a.JumpPressed, a.Confirm = true, true
}
}
in.Now = a
}
// demoActions is a fixed script so headless runs are reproducible.
func demoActions(tick int) Actions {
return Actions{
Confirm: tick == 30,
Right: tick > 60 && tick%160 < 120,
Jump: tick%90 < 18,
JumpPressed: tick%90 == 0,
}
}
assets/starter/licenses/Go-Fonts.txtThese fonts were created by the Bigelow & Holmes foundry specifically for the Go project. See https://blog.golang.org/go-fonts for details. They are licensed under the same open source license as the rest of the Go project's software: Copyright (c) 2016 Bigelow & Holmes Inc.. All rights reserved. Distribution of this font is governed by the following license. If you do not agree to this license, including the disclaimer, do not distribute or modify this font. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. DISCLAIMER: THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
assets/starter/main.go// Starter game for Ebitengine v2.10: title screen, a small platformer level,
// keyboard/gamepad/touch input, text, sound and pause.
//
// go run . play
// go run . -demo -shot out.png scripted input, save the screen at tick 240, exit
package main
import (
"flag"
"fmt"
"log"
"github.com/hajimehoshi/ebiten/v2"
)
// version is stamped by release builds: go build -ldflags "-X main.version=1.2.0".
var version = "dev"
// The logical screen. Everything is drawn in these units; Ebitengine scales the
// result to the window.
const (
screenW = 640
screenH = 360
)
func main() {
shot := flag.String("shot", "", "write a PNG of the screen to this path and exit (headless checks)")
shotTick := flag.Int("shot-tick", 240, "tick at which -shot is taken")
demo := flag.Bool("demo", false, "drive the game with a fixed input script instead of devices")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *shotTick < 1 {
log.Fatal("-shot-tick must be positive")
}
if *showVersion {
fmt.Println(version)
return
}
g, err := NewGame(Options{ShotPath: *shot, ShotTick: *shotTick, Demo: *demo})
if err != nil {
log.Fatal(err)
}
ebiten.SetWindowTitle("Starter")
ebiten.SetWindowSize(screenW*2, screenH*2)
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
if *shot != "" {
// Exactly one Update per Draw makes the requested tick reproducible.
// This capture mode runs as fast as possible; interactive play uses 60 TPS.
ebiten.SetTPS(ebiten.SyncWithFPS)
ebiten.SetVsyncEnabled(false)
ebiten.SetWindowVisible(false)
ebiten.SetRunnableOnUnfocused(true)
}
if err := ebiten.RunGame(g); err != nil {
log.Fatal(err)
}
}
assets/starter/scenes.gopackage main
import (
"fmt"
"image/color"
"math"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"starter/world"
)
// ---------------------------------------------------------------- title
type titleScene struct{ g *Game }
func newTitleScene(g *Game) *titleScene { return &titleScene{g: g} }
func (s *titleScene) Update(g *Game) error {
if g.input.Now.Confirm {
g.SetScene(newPlayScene(g))
}
return nil
}
func (s *titleScene) Draw(screen *ebiten.Image) {
screen.Fill(colBG)
drawCentered(screen, "STARTER", s.g.assets.Title, screenW/2, screenH/2-40, colText)
// Blink from the tick counter: Draw reads state, it never advances it.
if s.g.tick/30%2 == 0 {
drawCentered(screen, "Enter / Start / tap to play", s.g.assets.Body, screenW/2, screenH/2+30, colText)
}
drawCentered(screen, "v"+version, s.g.assets.Body, screenW/2, screenH-24, colText)
}
// ---------------------------------------------------------------- play
const level = `
################################################################################
#..............................................................................#
#..............................................................................#
#..............................................................................#
#..............................................................................#
#.........................................o.o.o................................#
#........................................#######...............................#
#..............................................................................#
#..........................o.o.....................................o.o.o.......#
#.........................#####...................................#######......#
#..............................................................................#
#.................o.....................o.o...............o.o..................#
#................###...................#####.............#####.................#
#..............................................................................#
#.........o.........................o..........................................#
#........###......................#####..............................####......#
#..............................................................................#
#.P.......o.o.o.o.......o.o.o.o..........o.o.o.o..........o.o.o.o..............#
#..............................................................................#
#.................................####.................................####....#
######################......############################.....###################
######################......############################.....###################
################################################################################
`
type playScene struct {
g *Game
w *world.World
camX, camY float64
paused bool
}
func newPlayScene(g *Game) *playScene {
s := &playScene{g: g, w: world.Parse(level)}
s.camX, s.camY = s.cameraTarget()
return s
}
func (s *playScene) Update(g *Game) error {
in := g.input.Now
if in.Pause {
s.paused = !s.paused
}
if s.paused {
return nil // freeze the world, keep reading input
}
s.w.Step(world.Input{Left: in.Left, Right: in.Right, JumpPressed: in.JumpPressed, JumpHeld: in.Jump})
for _, e := range s.w.Events {
switch e {
case world.EventJump:
g.sound.Play(g.sound.jump)
case world.EventLand:
g.sound.Play(g.sound.land)
case world.EventCoin:
g.sound.Play(g.sound.coin)
}
}
s.w.Events = s.w.Events[:0]
// Ease the camera toward its target a fixed fraction per tick.
tx, ty := s.cameraTarget()
s.camX += (tx - s.camX) * 0.15
s.camY += (ty - s.camY) * 0.15
return nil
}
// cameraTarget centres the player and clamps to the level.
func (s *playScene) cameraTarget() (float64, float64) {
p := s.w.Player.Box
lw, lh := s.w.Size()
x := min(max(p.X+p.W/2-screenW/2, 0), max(lw-screenW, 0))
y := min(max(p.Y+p.H/2-screenH/2, 0), max(lh-screenH, 0))
return x, y
}
func (s *playScene) Draw(screen *ebiten.Image) {
screen.Fill(colBG)
a := s.g.assets
// Round the camera once so every sprite moves by whole pixels together.
cx, cy := math.Round(s.camX), math.Round(s.camY)
// Draw only the tiles on screen. Successive DrawImage calls with the same
// source atlas, filter and blend can be batched by Ebitengine.
ts := float64(world.TileSize)
x0, y0 := int(cx/ts), int(cy/ts)
x1, y1 := int((cx+screenW)/ts), int((cy+screenH)/ts)
op := &ebiten.DrawImageOptions{}
cols, rows := s.w.Cells()
for ty := max(y0, 0); ty <= min(y1, rows-1); ty++ {
for tx := max(x0, 0); tx <= min(x1, cols-1); tx++ {
if !s.w.Solid(tx, ty) {
continue
}
op.GeoM.Reset()
op.GeoM.Translate(float64(tx)*ts-cx, float64(ty)*ts-cy)
screen.DrawImage(a.Tile, op)
}
}
for i, c := range s.w.Coins {
bob := math.Round(2 * math.Sin(float64(s.g.tick+i*7)/10))
op.GeoM.Reset()
op.GeoM.Translate(c.X-cx, c.Y-cy+bob)
screen.DrawImage(a.Coin, op)
}
p := s.w.Player.Box
op.GeoM.Reset()
op.GeoM.Translate(math.Round(p.X-cx), math.Round(p.Y-cy))
screen.DrawImage(a.Player, op)
hud := &text.DrawOptions{}
hud.GeoM.Translate(12, 8)
hud.ColorScale.ScaleWithColor(colText)
text.Draw(screen, fmt.Sprintf("Coins %d [Esc] pause [F11] fullscreen", s.w.Score), a.Body, hud)
if s.paused {
vector.FillRect(screen, 0, 0, screenW, screenH, color.RGBA{0, 0, 0, 0xa0}, false)
drawCentered(screen, "PAUSED", a.Title, screenW/2, screenH/2, colText)
}
}
// drawCentered draws one line of text centred on (x, y).
func drawCentered(dst *ebiten.Image, str string, face *text.GoTextFace, x, y float64, clr color.Color) {
op := &text.DrawOptions{}
op.GeoM.Translate(x, y)
op.PrimaryAlign = text.AlignCenter
op.SecondaryAlign = text.AlignCenter
op.ColorScale.ScaleWithColor(clr)
text.Draw(dst, str, face, op)
}
assets/starter/sound.gopackage main
import (
"encoding/binary"
"math"
"github.com/hajimehoshi/ebiten/v2/audio"
)
// One audio context per process, at one sample rate. Author or convert all
// sound files to this rate (see references/audio.md).
const sampleRate = 48000
type Sound struct {
ctx *audio.Context
jump, coin []byte // decoded once: 32-bit float, little endian, stereo
land []byte
Volume float64
}
func NewSound() *Sound {
return &Sound{
ctx: audio.NewContext(sampleRate),
jump: synth(0.09, 330, 660, 0.4, 1),
land: synth(0.05, 140, 90, 0.9, 2),
coin: synth(0.14, 988, 1319, 0.15, 3),
Volume: 0.6,
}
}
// Play starts a short effect. A new Player per effect is cheap and lets the
// same sound overlap itself; never reuse one Player for rapid repeats.
func (s *Sound) Play(pcm []byte) {
p := s.ctx.NewPlayerF32FromBytes(pcm)
p.SetVolume(s.Volume)
p.Play()
}
// synth makes a placeholder effect: a pitch sweep with a noise click at the
// start so the onset is audible. Replace with designed sounds or files.
func synth(seconds, f0, f1, noise float64, seed uint32) []byte {
n := int(seconds * sampleRate)
out := make([]byte, n*8)
phase := 0.0
r := seed*2654435761 + 1
for i := 0; i < n; i++ {
t := float64(i) / sampleRate
u := t / seconds
f := f0 + (f1-f0)*u
phase += 2 * math.Pi * f / sampleRate
r = r*1664525 + 1013904223
white := float64(r>>8)/float64(1<<24)*2 - 1
env := math.Exp(-t * 30)
click := noise * white * math.Exp(-t*900)
v := float32((0.5*math.Sin(phase)+0.2*math.Sin(3*phase))*env + click)
binary.LittleEndian.PutUint32(out[i*8:], math.Float32bits(v*0.5))
binary.LittleEndian.PutUint32(out[i*8+4:], math.Float32bits(v*0.5))
}
return out
}
assets/starter/web/index.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Starter</title>
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
#status { position: fixed; inset: 0; display: grid; place-items: center;
color: #ccc; font: 16px system-ui, sans-serif; }
</style>
</head>
<body>
<div id="status">Loading…</div>
<!-- Portal SDKs (ads, analytics) go here, before wasm_exec.js. -->
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
const status = document.getElementById("status");
WebAssembly.instantiateStreaming(fetch("game.wasm"), go.importObject)
.then(async r => { status.remove(); await go.run(r.instance); })
.catch(e => {
document.body.appendChild(status);
status.textContent = "Could not run the game: " + e;
});
</script>
</body>
</html>
assets/starter/world/world_test.gopackage world
import (
"fmt"
"slices"
"testing"
)
const testLevel = `
##########
#........#
#.P...o..#
##########
`
func settle(w *World) {
for i := 0; i < 30; i++ {
w.Step(Input{})
}
}
func TestPlayerLandsOnFloor(t *testing.T) {
w := Parse(testLevel)
settle(w)
if !w.Player.OnGround {
t.Fatal("player should rest on the floor")
}
if got, want := w.Player.Box.Y+w.Player.Box.H, float64(3*TileSize); got != want {
t.Fatalf("feet at %v, want %v", got, want)
}
}
func TestJumpLeavesGroundAndReturns(t *testing.T) {
w := Parse(testLevel)
settle(w)
w.Events = nil
w.Step(Input{JumpPressed: true, JumpHeld: true})
if w.Player.OnGround || w.Player.VY >= 0 {
t.Fatal("jump did not start")
}
for i := 0; i < 120; i++ {
w.Step(Input{JumpHeld: true})
}
if !w.Player.OnGround {
t.Fatal("player never landed")
}
if len(w.Events) < 2 || w.Events[0] != EventJump {
t.Fatalf("events %v", w.Events)
}
}
func TestWallStopsPlayer(t *testing.T) {
w := Parse(testLevel)
settle(w)
for i := 0; i < 200; i++ {
w.Step(Input{Left: true})
}
if w.Player.Box.X != TileSize {
t.Fatalf("player at x=%v, want flush with the wall at %v", w.Player.Box.X, TileSize)
}
}
func TestCoinIsCollectedOnce(t *testing.T) {
w := Parse(testLevel)
settle(w)
for i := 0; i < 200; i++ {
w.Step(Input{Right: true})
}
if w.Score != 1 || len(w.Coins) != 0 {
t.Fatalf("score %d, coins left %d", w.Score, len(w.Coins))
}
}
func TestStepIsDeterministic(t *testing.T) {
run := func() Player {
w := Parse(testLevel)
for i := 0; i < 300; i++ {
w.Step(Input{Right: i%40 < 25, JumpPressed: i%50 == 0, JumpHeld: i%50 < 10})
}
return w.Player
}
if run() != run() {
t.Fatal("same inputs produced different states")
}
}
// More headroom than testLevel: the small fixture hits its ceiling immediately
// and cannot distinguish the two jump heights.
const tallLevel = `
##############
#............#
#............#
#............#
#............#
#............#
#............#
#............#
#.P..........#
##############
`
func TestReleaseShortensJump(t *testing.T) {
peak := func(held bool) float64 {
w := Parse(tallLevel)
settle(w)
w.Step(Input{JumpPressed: true, JumpHeld: true})
y := w.Player.Box.Y
for i := 0; i < 90; i++ {
w.Step(Input{JumpHeld: held})
y = min(y, w.Player.Box.Y)
}
return y
}
if high, low := peak(true), peak(false); high >= low-10 {
t.Fatalf("held jump peak %v, released jump peak %v: expected a taller held jump", high, low)
}
}
func TestBufferedJumpStartsAfterLanding(t *testing.T) {
w := Parse(tallLevel)
w.Player.Box.Y = 9*TileSize - w.Player.Box.H - 1
w.Player.VY = 2
w.Step(Input{JumpPressed: true, JumpHeld: true})
if !w.Player.OnGround {
t.Fatal("test setup should land on the press tick")
}
w.Step(Input{JumpHeld: true})
if w.Player.OnGround || w.Player.VY >= 0 {
t.Fatal("jump pressed before landing was not buffered")
}
}
func TestCoyoteJumpHasLimitedWindow(t *testing.T) {
for _, delay := range []int{2, 10} {
t.Run(fmt.Sprint(delay), func(t *testing.T) {
w := Parse(tallLevel)
settle(w)
// Remove the floor after establishing ground contact.
for x := 1; x < w.cols-1; x++ {
w.solid[(w.rows-1)*w.cols+x] = false
}
// Leave room to fall: outside the level is solid and would
// otherwise provide a second floor one tile below this ledge.
w.rows += 20
w.solid = append(w.solid, make([]bool, w.cols*20)...)
for i := 0; i < delay; i++ {
w.Step(Input{})
}
w.Events = nil
w.Step(Input{JumpPressed: true, JumpHeld: true})
jumped := slices.Contains(w.Events, EventJump)
if jumped != (delay < coyoteTicks) {
t.Fatalf("jump after %d ticks off ground: %v", delay, jumped)
}
})
}
}
assets/starter/world/world.go// Package world holds the game rules. It does not import Ebitengine, so it can
// be unit-tested with plain `go test` and stepped deterministically.
package world
import "strings"
// TileSize is the size of one level cell in world units (pixels).
const TileSize = 16
// Fixed-step tuning, expressed per tick (Update runs 60 times per second).
const (
gravity = 0.35
maxFall = 6.0
runSpeed = 2.2
jumpSpeed = 6.2
// coyoteTicks lets a jump register shortly after walking off a ledge.
coyoteTicks = 6
// bufferTicks remembers a jump pressed shortly before landing.
bufferTicks = 6
)
// Rect is an axis-aligned box in world units.
type Rect struct{ X, Y, W, H float64 }
func (r Rect) Overlaps(o Rect) bool {
return r.X < o.X+o.W && o.X < r.X+r.W && r.Y < o.Y+o.H && o.Y < r.Y+r.H
}
// Input is the per-tick intent, already translated from devices to actions.
type Input struct {
Left, Right bool
JumpPressed bool // pressed this tick
JumpHeld bool
}
// Player is the controllable body.
type Player struct {
Box Rect
VX, VY float64
OnGround bool
coyote int
buffer int
}
// World is one level's state.
type World struct {
cols, rows int
solid []bool
Coins []Rect
Player Player
Score int
Events []Event // filled during Step; read and cleared by the caller
}
// Event tells the presentation layer what happened (for sound, particles...).
type Event int
const (
EventJump Event = iota
EventLand
EventCoin
)
// Parse builds a world from rows of text: '#' solid, 'o' coin, 'P' player start.
func Parse(level string) *World {
lines := strings.Split(strings.Trim(level, "\n"), "\n")
w := &World{rows: len(lines)}
for _, l := range lines {
w.cols = max(w.cols, len(l))
}
w.solid = make([]bool, w.cols*w.rows)
for y, l := range lines {
for x, c := range l {
px, py := float64(x*TileSize), float64(y*TileSize)
switch c {
case '#':
w.solid[y*w.cols+x] = true
case 'o':
w.Coins = append(w.Coins, Rect{px + 4, py + 4, 8, 8})
case 'P':
w.Player.Box = Rect{px + 2, py + 2, 12, 14}
}
}
}
return w
}
// Size returns the level size in world units.
func (w *World) Size() (float64, float64) {
return float64(w.cols * TileSize), float64(w.rows * TileSize)
}
// Cells returns the level size in cells.
func (w *World) Cells() (cols, rows int) { return w.cols, w.rows }
// Solid reports whether the cell (cx, cy) blocks movement. Outside is solid.
func (w *World) Solid(cx, cy int) bool {
if cx < 0 || cy < 0 || cx >= w.cols || cy >= w.rows {
return true
}
return w.solid[cy*w.cols+cx]
}
// Step advances the world by exactly one tick.
func (w *World) Step(in Input) {
p := &w.Player
p.VX = 0
if in.Left {
p.VX -= runSpeed
}
if in.Right {
p.VX += runSpeed
}
if in.JumpPressed {
p.buffer = bufferTicks
} else if p.buffer > 0 {
p.buffer--
}
if p.OnGround {
p.coyote = coyoteTicks
} else if p.coyote > 0 {
p.coyote--
}
if p.buffer > 0 && p.coyote > 0 {
p.VY = -jumpSpeed
p.buffer, p.coyote = 0, 0
w.Events = append(w.Events, EventJump)
}
// Releasing jump early cuts the rise: variable jump height.
if !in.JumpHeld && p.VY < -jumpSpeed/2 {
p.VY = -jumpSpeed / 2
}
p.VY = min(p.VY+gravity, maxFall)
wasOnGround := p.OnGround
w.moveX(p.VX)
p.OnGround = false
w.moveY(p.VY)
if p.OnGround && !wasOnGround {
w.Events = append(w.Events, EventLand)
}
kept := w.Coins[:0]
for _, c := range w.Coins {
if p.Box.Overlaps(c) {
w.Score++
w.Events = append(w.Events, EventCoin)
continue
}
kept = append(kept, c)
}
w.Coins = kept
}
// moveX and moveY resolve one axis at a time so corners never snag.
func (w *World) moveX(dx float64) {
p := &w.Player
p.Box.X += dx
if c, ok := w.hit(p.Box); ok {
if dx > 0 {
p.Box.X = float64(c.X*TileSize) - p.Box.W
} else if dx < 0 {
p.Box.X = float64((c.X + 1) * TileSize)
}
}
}
func (w *World) moveY(dy float64) {
p := &w.Player
p.Box.Y += dy
if c, ok := w.hit(p.Box); ok {
if dy > 0 {
p.Box.Y = float64(c.Y*TileSize) - p.Box.H
p.OnGround = true
} else if dy < 0 {
p.Box.Y = float64((c.Y + 1) * TileSize)
}
p.VY = 0
}
}
type cell struct{ X, Y int }
// hit returns the first solid cell overlapping r. The tiny epsilon keeps a box
// that exactly touches a wall from counting as overlapping it.
func (w *World) hit(r Rect) (cell, bool) {
const eps = 1e-6
x0, y0 := int(r.X/TileSize), int(r.Y/TileSize)
x1, y1 := int((r.X+r.W-eps)/TileSize), int((r.Y+r.H-eps)/TileSize)
if r.X < 0 {
x0 = -1
}
if r.Y < 0 {
y0 = -1
}
for cy := y0; cy <= y1; cy++ {
for cx := x0; cx <= x1; cx++ {
if w.Solid(cx, cy) {
return cell{cx, cy}, true
}
}
}
return cell{}, false
}
references/architecture-and-testing.md# Architecture and testing
API baseline: Ebitengine v2.10.5. Snippets illustrate patterns to adapt; a
production project must supply imports, initialization and validation.
## Package layout
```text
mygame/
├── go.mod
├── main.go # flags, window setup, ebiten.RunGame
├── game.go # type Game: Update/Draw/Layout, scene switching
├── input.go # devices -> actions
├── assets.go # //go:embed + decoding, done once
├── sound.go # audio context and players
├── scenes.go # title, play, results...
├── world/ # rules: no Ebitengine import, unit-tested
└── assets/ # png, ogg, wav, ttf + licence files
```
Keep `main` thin. Rules deciding *what happens* (movement, collision, scoring,
turn order, card effects, AI) are easier to test in a package without Ebitengine,
using explicit inputs and simulation steps where appropriate. It can then be tested
with `go test` on any machine, replayed, and reused on a server. The starter's
`world` package is this split.
## Scenes
A scene owns its state and implements `Update` and `Draw`. Switch scenes between
ticks, and keep a stack when a scene overlays another (pause, dialogue, inventory).
```go
type Scene interface {
Update(m *Manager) error
Draw(screen *ebiten.Image)
}
type Manager struct {
stack []Scene // top is active; lower scenes stay drawn (pause overlays)
next Scene
fade int // ticks left in the current fade
fadeLen int
}
func (m *Manager) Push(s Scene) { m.stack = append(m.stack, s) }
func (m *Manager) Pop() { m.stack = m.stack[:len(m.stack)-1] }
// Replace fades out, swaps the whole stack for s, and fades in.
func (m *Manager) Replace(s Scene, ticks int) {
ticks = max(0, ticks)
m.next, m.fade, m.fadeLen = s, ticks*2, ticks
}
func (m *Manager) Update() error {
if m.fade == 0 && m.next != nil { // zero-length transition: next tick
m.stack, m.next = []Scene{m.next}, nil
}
if m.fade > 0 {
m.fade--
if m.fade == m.fadeLen && m.next != nil { // midpoint: screen is black
m.stack, m.next = []Scene{m.next}, nil
}
return nil // ignore input while fading
}
return m.stack[len(m.stack)-1].Update(m)
}
func (m *Manager) Draw(screen *ebiten.Image) {
for _, s := range m.stack {
s.Draw(screen)
}
if m.fade > 0 {
t := float32(m.fade-m.fadeLen) / float32(m.fadeLen) // 1 -> 0 while fading out
if t < 0 {
t = -t // 0 -> 1 while fading in
}
alpha := uint8(255 * (1 - t))
w, h := screen.Bounds().Dx(), screen.Bounds().Dy()
vector.FillRect(screen, 0, 0, float32(w), float32(h), color.RGBA{0, 0, 0, alpha}, false)
}
}
```
Load a level's heavy assets when its scene is created, not during the fade.
Initialize the manager with a non-empty stack; prevent popping the last scene
and reject nil replacements at the call boundary. For loading that would stall
play, use a loading scene and a deliberate CPU decoding/GPU upload policy.
## Resource and callback lifetime
Make ownership explicit for a scene's load jobs, cached resources, subscriptions,
timers and audio streams. Deduplicate concurrent loads of the same asset when
needed, but clear failed in-flight entries so a retry can actually run. A shared
resource must not be released just because one scene stopped using it.
For asynchronous work, tag results with a scene/request generation. Apply them
at an update boundary only if that owner is still current; cancel or discard a
late completion after leaving/restarting. Unsubscribe scene listeners and stop
owned workers on exit so revisiting a screen does not double-trigger events.
Load and validate the next scene before replacing a working one when failure
needs to be recoverable. This adapts resource and scene lifecycle principles from
[game-engine](https://github.com/github/awesome-copilot/blob/2d4b998d2df28917074ee33db45c9601484c72ad/skills/game-engine/references/game-engine-core-principles.md).
It does not require a reference-counted asset manager, ECS or custom allocator
in every Go game.
## Entities
Plain structs in slices are a useful starting point. Reuse capacity in hot paths;
structs alone do not guarantee allocation-free updates. Remove by swapping with
the last element only when entity order is unimportant:
```go
type Bullet struct {
X, Y, VX, VY float64
Life int
}
func stepBullets(bs []Bullet) []Bullet {
for i := 0; i < len(bs); {
b := &bs[i]
b.X += b.VX
b.Y += b.VY
b.Life--
if b.Life <= 0 {
bs[i] = bs[len(bs)-1]
bs = bs[:len(bs)-1]
continue
}
i++
}
return bs
}
```
- Collision for platformers and top-down games: move one axis at a time and
resolve against the tile grid (starter `world.moveX`/`moveY`). Choose a broad phase
(for example a uniform grid) when collision profiling warrants it.
- Fast objects can pass through thin walls in one tick; use swept collision or
substeps chosen for the collider geometry. Test high speeds and corners;
the starter's axis-separated grid solver is not a general physics engine.
- Collect what happened in a tick as events (jumped, landed, hit) and let the
presentation layer play sounds and spawn particles from them.
## Randomness
Use a seeded generator owned by the game state so runs can be reproduced with
the same input sequence, content, rules version and update order:
```go
func newRNG(seed uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
}
```
(`math/rand/v2`.) Keep separate generators for gameplay and cosmetic effects so
particles do not change the gameplay sequence.
The seed alone is insufficient: avoid gameplay decisions based on map iteration
order or wall-clock time, and record inputs and the game version for replays.
## Saving
```go
type SaveData struct {
Version int `json:"version"`
BestTime int `json:"best_time_ticks"`
Muted bool `json:"muted"`
}
func savePath(game string) (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, game, "save.json"), nil
}
func Save(game string, d SaveData) error {
p, err := savePath(game)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(p), ".save-*.tmp")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp)
if _, err := f.Write(b); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, p)
}
func Load(game string) (SaveData, error) {
d := SaveData{Version: 1}
p, err := savePath(game)
if err != nil {
return d, err
}
b, err := os.ReadFile(p)
if errors.Is(err, fs.ErrNotExist) {
return d, nil // first run
}
if err != nil {
return d, err
}
if err := json.Unmarshal(b, &d); err != nil {
return d, err
}
return d, nil
}
```
- This writes a separate temporary file before replacing the old save. It is
not a universal crash-safety guarantee: Go does not promise atomic rename on
non-Unix platforms, and power-loss durability can require directory syncing
and platform-specific replacement. Keep a validated backup where save loss
matters. Serialize saves and validate/migrate decoded versions before use.
- Save before quitting: `ebiten.SetWindowClosingHandled(true)`, then in `Update`
check `ebiten.IsWindowBeingClosed()`, save, and return `ebiten.Termination`.
This handles a desktop close request, not a process kill, browser unload or
mobile termination; save progress at appropriate checkpoints too.
- Browsers have no user config directory; store small saves in `localStorage`
through `syscall/js` behind a `//go:build js` file.
- Include a format version, migrate supported old formats and report unsupported
versions without overwriting them. Recover malformed data from a validated
backup or show a recoverable error. For engine-neutral save design and
migration, see `save-systems`.
## Testing
For controlled action sequences and matching state/image diagnostics, read
[Playtest diagnostics](playtest-diagnostics.md). The capture helper below only
writes pixels; it does not establish that a complete interaction succeeded.
1. **Rules:** `go test ./world/...` with table tests and fixed seeds. Step the
world N ticks with scripted inputs and assert positions, scores and events.
Add a determinism test (same inputs twice, same state).
2. **Static checks:** `go vet ./...` and the skill's
`scripts/check_ebitengine.py .`. The checker is a heuristic, not a proof of
correct code or API support.
3. **Rendering smoke test:** run the game with scripted input and capture a frame
(the starter's `-demo -shot FILE -shot-tick N`), on Linux under `xvfb-run -a`
with a null audio device if there is no sound card (see `audio.md`). Look at
the PNG; for regression checks compare against a stored image with a small
per-channel tolerance, since GPU drivers differ slightly.
4. **Opt-in VM testing:** Ebitengine's
`run-ebitengine-app-headless` skill, when present for the project's pinned
version, drives a game through the experimental
`exp/vmhost` package: injected keyboard/mouse/touch/gamepad input, ticks
faster than real time, frame readback, observed audio. Rebuild the app with
`ebitenginevmguest` and configure `EBITENGINE_VM_ENDPOINT`, or use
`RunGameOptions.VMGuestEndpoint`. It requires matching engine versions and
a graphics environment for the host; it is not a software renderer.
5. **Builds:** compile the advertised targets (see `project-and-builds.md`), then
run the resulting artifacts in their target environments. Compilation alone
does not validate packaging, SDKs, input, storage or audio.
6. **People:** feel, difficulty and audio quality still need someone playing.
Report what was not checked.
On Linux, any tests importing graphics-backed Ebitengine packages may require
initializing a display even without a test calling `RunGame`; run those under
`xvfb-run -a`. Keep pure rules tests independent of the engine.
Capturing a frame from inside the game (call from `Draw`; pixels are
premultiplied RGBA, which equals straight RGBA when the screen is opaque):
```go
func savePNG(screen *ebiten.Image, path string) error {
img := image.NewRGBA(screen.Bounds())
screen.ReadPixels(img.Pix)
f, err := os.Create(path)
if err != nil {
return err
}
if err := png.Encode(f, img); err != nil {
f.Close()
return err
}
return f.Close()
}
```
## Debugging
- `ebitenutil.DebugPrint(screen, fmt.Sprintf("TPS %.1f FPS %.1f", ebiten.ActualTPS(), ebiten.ActualFPS()))`.
- `go run -tags=ebitenginedebug .` logs the draw commands issued each frame.
- Desktop games keep running when unfocused (the default of
`ebiten.SetRunnableOnUnfocused`); call it with `false` to pause in the
background. Browsers throttle background tabs regardless.
- Errors returned from `Update` stop the game and come back from `RunGame`; return
wrapped errors instead of `log.Fatal` deep inside scenes.
Source contracts: [game loop and focus policy](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/run.go)
and [Go file replacement guarantees](https://pkg.go.dev/os#Rename).
references/assets-and-animation.md# Assets that belong to one game Read when producing/importing sprites, backgrounds, UI art or animation. These are asset-integration checks, not a requirement to generate images or build a large asset pipeline for a small game. ## Establish shared visual rules and integration data Describe the rendering/line language, viewing angle, lighting and palette **by role**: world, player/interactable, threat, reward and UI. A shared list of colors can make everything blend together; shared style should preserve distinctions needed for play. A side-view character and top-down environment will not agree merely because their colors match. Reuse a compact style description across art requests, but verify actual results; identical prompt text cannot ensure cohesion. For a set of assets, keep enough metadata to assemble it reliably: stable ID, role, file/source and licence, intended world/logical size, anchor/pivot, facing, animation layout and timing. A table or small data file is sufficient; no fixed CSV schema is necessary. Separate source-image resolution from in-game scale. Compare important objects in common world units and against the intended background at play size, rather than approving isolated full-resolution images. This adapts the role palette, scale comparison and asset-contract ideas in [Higgsfield's stylization reference](https://github.com/higgsfield-ai/skills/blob/f83af0bc1d937c8119099a11f8ebbf5e6fb99819/higgsfield-websites/references/game-stylization.md). Use the current asset tool's actual capabilities for transparency and output formats; do not inherit a provider-specific claim that all models require chroma key. ## Keep animation registration stable - Give frames consistent cell dimensions and a stable pivot appropriate to the action, such as a ground-contact point. Independently trimming every frame and centering the remaining pixels introduces jitter. A union of occupied bounds across the sequence can preserve registration **when the source frames were already aligned**; it cannot repair a drifting camera or a moving subject. - Reserve bounds for the largest pose/effect. Check the full motion, thin parts, alpha edges on light and dark backgrounds, and transitions between clips. A good first frame does not establish a good sheet. - Inspect a loop's seam and timing. Remove a terminal frame only when it repeats the start pose/timing unnecessarily; do not assume an exported/generated loop has identical endpoints. One-shot attack/recovery poses have different needs. - Store actual frame count, grid dimensions and duration/markers. A partial final row must not animate through empty cells. For a grid sheet, cell coordinates come from `column = i % columns`, `row = i / columns`; the simple `NewAnim` example in [Rendering](rendering.md) assumes a horizontal strip. - Choose sheet dimensions and frame count from motion/readability and target memory/texture constraints. A long strip can exceed the target's limits; split clips or pack a grid as needed. More nearly identical frames can add cost or shimmer without improving motion. These checks adapt the registration and loop-assembly lessons in [Higgsfield's 2D animation pipeline](https://github.com/higgsfield-ai/skills/blob/f83af0bc1d937c8119099a11f8ebbf5e6fb99819/higgsfield-websites/references/game-2d-animation.md). Its provider commands, mandatory frame counts and fixed size limits are not Ebitengine requirements. ## Connect art to rules deliberately Keep the collision body and visual transform distinct. A squashed sprite or shifted pivot should not silently change hit detection. If an attack's contact occurs at an animation marker, process that marker in the simulation/update timeline and emit the event once; a frame selected in `Draw` must not cause damage. Verify pose, hitbox, particle location and sound onset together. Changing clip speed must not accidentally duplicate or skip a gameplay event. Inspect a small contact sheet at the intended relative sizes, then the assets in motion in the game. This catches mismatched outlines/perspective, invisible threats, sliding feet, cropped attacks and different anchors between clips. Preserve the source and integration metadata needed to reproduce a later fix.
references/audio.md# Audio
Ebitengine v2.10, package `github.com/hajimehoshi/ebiten/v2/audio` with the
`wav`, `vorbis` and `mp3` decoders. API baseline: v2.10.5. Snippets require the
project's imports and error handling around their callers.
## Rules
- **One context per process.** `audio.NewContext(rate)` panics with
`audio: context is already created` on a second call. Create it at start-up
and pass it around; `audio.CurrentContext()` returns it.
- **One sample rate.** 48000 is a safe default (44100 also works). All players
run at the context's rate.
- **Decoders and resampling.**
| Decoder | Output | Resamples? | Play with |
| --- | --- | --- | --- |
| `wav/vorbis/mp3.DecodeF32(r)` | 32-bit float stereo | **no**: `Stream.SampleRate()` is the file's rate | `ctx.NewPlayerF32`, `NewPlayerF32FromBytes`, `NewInfiniteLoopF32` |
| `DecodeWithSampleRate(rate, r)` | 16-bit stereo | yes, to `rate` | `ctx.NewPlayer`, `NewPlayerFromBytes`, `NewInfiniteLoop` |
| `Decode(ctx, r)` | — | — | deprecated since v2.1 |
Mixing F32 streams with `NewPlayer` (or 16-bit streams with `NewPlayerF32`)
plays noise. A 44.1 kHz file through `DecodeF32` into a 48 kHz context plays
about 9% too fast and high. Convert assets to the context rate, or use
`DecodeWithSampleRate`.
- **Start sounds from `Update`**, where game events happen. `Draw` can run zero
or several times per tick.
## Effects and music
```go
const sampleRate = 48000
type Audio struct {
ctx *audio.Context
sfx map[string][]byte
bgm *audio.Player
}
func NewAudio() *Audio {
return &Audio{ctx: audio.NewContext(sampleRate), sfx: map[string][]byte{}}
}
// LoadSFX decodes a WAV once into memory. DecodeF32 does not resample, so the
// file must already be at the context's rate.
func (a *Audio) LoadSFX(name string, wavData []byte) error {
s, err := wav.DecodeF32(bytes.NewReader(wavData))
if err != nil {
return err
}
if s.SampleRate() != a.ctx.SampleRate() {
return fmt.Errorf("%s: %d Hz, want %d Hz (convert the file or use DecodeWithSampleRate)",
name, s.SampleRate(), a.ctx.SampleRate())
}
b, err := io.ReadAll(s)
if err != nil {
return err
}
a.sfx[name] = b
return nil
}
// PlaySFX makes a new short-lived player per call; overlapping plays are fine.
func (a *Audio) PlaySFX(name string, volume float64) {
b, ok := a.sfx[name]
if !ok {
return // alternatively report an asset-ID error during development
}
p := a.ctx.NewPlayerF32FromBytes(b)
p.SetVolume(volume)
p.Play()
}
// PlayBGM streams an Ogg file and loops it forever. Keep and reuse the player.
func (a *Audio) PlayBGM(oggData []byte) error {
s, err := vorbis.DecodeF32(bytes.NewReader(oggData))
if err != nil {
return err
}
if s.SampleRate() != a.ctx.SampleRate() {
return fmt.Errorf("bgm: %d Hz, want %d Hz", s.SampleRate(), a.ctx.SampleRate())
}
if s.Length() <= 0 {
return fmt.Errorf("bgm: loop needs a known positive PCM length")
}
loop := audio.NewInfiniteLoopF32(s, s.Length())
p, err := a.ctx.NewPlayerF32(loop)
if err != nil {
return err
}
if a.bgm != nil {
a.bgm.PauseAndStopReading()
}
a.bgm = p
a.bgm.SetVolume(0.5)
a.bgm.Play()
return nil
}
```
A file at another rate, decoded with resampling:
```go
func playResampled(ctx *audio.Context, wavData []byte) (*audio.Player, error) {
s, err := wav.DecodeWithSampleRate(ctx.SampleRate(), bytes.NewReader(wavData))
if err != nil {
return nil, err
}
return ctx.NewPlayer(s) // 16-bit stream: NewPlayer, not NewPlayerF32
}
```
- Short effects: decode bounded assets to bytes at load, new
player per play. Do not reuse one player for rapid repeats: `Rewind` on a playing
player cuts the previous sound. Limit simultaneous voices to fit the game's
mix and performance budget.
- Music: stream (do not `io.ReadAll` long tracks), one player, reused.
`NewInfiniteLoopWithIntroF32(src, introBytes, loopBytes)` plays an intro once
and loops the rest. Byte lengths for F32 stereo are `seconds × rate × 8`.
Looping an entire lossy file is not a guarantee of a seamless musical loop:
audition its join. The loop API can blend across the seam when source audio
extends beyond the loop end; provide intentional loop boundaries and a tail.
- Volume is linear 0–1 (`SetVolume`); a mute toggle sets it to 0. Fades: change
the volume a little each tick in `Update`.
- Use `PauseAndStopReading` before releasing or closing the source (v2.10
deprecates `Close`). This pauses and stops background reads; it does not
destroy the player, and `Play` can resume it.
`SetPosition`/`Position` replace the deprecated `Seek`/`Current`.
- Pitch variation for repeated effects: prepare 2–4 decoded variants (slightly
different pitches or recordings) and pick one at random; Ebitengine has no
per-player pitch control.
- Stereo panning: wrap the stream in your own `io.Reader` that scales left and
right samples; see `examples/audiopanning` for the pinned version.
## Browsers and mobile
- Browsers allow audio only after a user gesture. The context reports
`ctx.IsReady()` once audio can start; start music from a title-screen action
rather than at load. When embedding in a page, give the iframe
`allow="autoplay"`.
- On mobile, audio pauses with the app when the host calls `suspendGame()`; see
`project-and-builds.md`.
## Machines without a sound device
If no audio device can be opened and the app creates an audio context, the audio
context reports an error and `RunGame` returns
`audio: audio error: ... initialization failed ...`. For headless runs either
start a PulseAudio/PipeWire server or point ALSA at its null device:
```sh
null_audio_dir=$(mktemp -d)
printf 'pcm.!default { type null }\n' > "$null_audio_dir/asound.conf"
ALSA_CONFIG_PATH="$null_audio_dir/asound.conf" xvfb-run -a ./game
rm -r "$null_audio_dir"
```
This applies to Linux ALSA configuration; it does not produce audible output or
verify playback quality. Use an explicit project save path for automated runs
so tests do not overwrite player saves. Other audio backends need their own
virtual-device setup.
## Designing the sounds
This reference covers playback. For what the sounds should be (layering,
variation, mixing against music), use the game's brief and
[quality criteria](design-and-quality.md). Procedurally generated
PCM can be played directly: build little-endian float32 stereo bytes and pass
them to `NewPlayerF32FromBytes`, as the starter's `sound.go` does. Match attack,
material, pitch, duration and space to the event; a broadband transient helps
many contacts, but sustained ambience and soft UI feedback need different
envelopes. Check clipping, repeated playback, music masking and the loop join
by ear when playback is available; mark unlistened audio explicitly.
Source contracts: [players and lifecycle](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/audio/audio.go)
and [loop lengths and seam blending](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/audio/loop.go).
references/ci-and-release.md# CI and release automation The bundled workflow tests and cross-builds the starter on Linux. Ebitengine v2.10's desktop backend needs no C compiler; dependencies, SDKs and build tags can add requirements. Adapt the matrix to the game's promised targets. Native runtime, signing and store checks remain separate from cross-compilation. ## Workflow asset and its limits `new_game.py --ci` copies `assets/ci/github-actions.yml` to `.github/workflows/ci.yml` and the checker/packager to `tools/`. It does not push, publish or enable a repository. Inspect the generated workflow before use: its `v*` tag job creates a GitHub release with archives. On branch pushes and pull requests, on `ubuntu-24.04`: 1. Check formatting and run `go vet ./...`. 2. Run `go test ./...` under Xvfb with `ALSA_CONFIG_PATH` pointing to a temporary null ALSA configuration. This accommodates packages initializing graphics. 3. Run the heuristic checker; review warnings in context. 4. Build and smoke-run with `-demo -shot shot.png -shot-tick 240`, with a timeout, virtual display and null audio. Require a nonempty PNG and upload it for inspection. Existence alone is not visual acceptance. 5. Cross-build Windows amd64, macOS amd64/arm64, Linux amd64 and js/wasm. On version tags such as `v1.2.3` or `v1.2.3-beta.1`, after tests pass: stamp the binary version, build desktop/WASM artifacts, assemble an unsigned universal macOS `.app`, archive and attach to a GitHub release. The binary retains the full version; Apple metadata uses the numeric version and CI run number. The general Windows build sets `-H=windowsgui`; Steam-specific workarounds are not applied automatically. Only the release job has `contents: write`. Local helper/build checks and workflow linting do not constitute a successful GitHub Actions run. Check the first actual run, its permissions and logs. The workflow does not sign/notarize, validate store services, or run Windows/macOS artifacts. Add native jobs where the delivery needs runtime evidence. Adapting it: - Replace scripted inputs and capture points when the game changes. Add more meaningful scenarios; retain smoke coverage rather than relying only on builds. - If rules/tests require cgo or native SDKs, change `CGO_ENABLED`, install the toolchain and split targets accordingly. - The workflow copies `licenses/` into release archives, including the bundled Go Regular font notice. Add the required notices for the game's assets and linked libraries; the supplied font notice is not an exhaustive dependency licence inventory. - For macOS, sign/notarize the final bundle on a Mac, use the correct store entitlements, staple and verify the extracted archive. See [Publishing/platforms](publishing-and-platforms.md). - Store uploads need the actual store tooling and credentials, within the user's requested publication scope; do not add upload jobs by default. ## Version stamping and traceability ```go var version = "dev" // stamped by -ldflags "-X main.version=1.2.0" ``` The starter prints this with `-version` and shows it on its title screen. Verify the output from the stamped binary: a wrong symbol can leave `dev` unchanged. Keep dependency versions in `go.mod`/`go.sum`; `go version -m` inspects embedded module information. `-trimpath` reduces local path exposure. The template uses `-buildvcs=false`; use your repository's policy for recording commit provenance. ## Visual regression Store reference renders for scripted states when accidental visual changes matter. Compare with a tolerance suited to the renderer/driver and inspect differences. Xvfb is a virtual display, not a software renderer: Ebitengine still uses its graphics backend, with a hardware or Mesa software implementation depending on the environment. Pin the CI image/driver where repeatability matters. A repeated PNG in one environment does not guarantee identical pixels across platforms. Before a release, inspect the meaningful states, run the packaged build on each promised platform, check browser hosting/focus/audio where applicable and verify version/licence files in the final archives. CI green is evidence for the checks it ran, not a complete playtest.
references/design-and-quality.md# Design and finish a game Use for a new game or substantial redesign. A tiny arcade game can be finished through a few deliberate passes. Repeated internal testing is part of one delivery; no skill guarantees the first untested implementation will be fun. ## Establish the product before choosing systems Record a short brief in the project with concrete answers: - **Player experience:** the repeated decision, information available and visible consequence. A theme or list of engine features is not the core loop. - **Complete scope:** how play starts, what progress means, what ends/sustains a session and how to retry/continue. State the content promised in this build. - **Presentation:** visual language, scale, focal point, motion and sound character. Use a coherent palette, typography and shape/sprite language tied to the game. Quality need not mean detailed assets or expensive effects. - **Delivery:** platform, viewport, controls, language, persistence and relevant accessibility needs. Keep unknown assumptions visible. - **Acceptance:** observable criteria for this game: a new player understands the first goal, a legal move has immediate feedback, failure permits a clean retry, text fits and the requested build launches. Add genre-specific criteria. If genre/content is open, pick a focused concept and proceed. Keep scope small enough to finish; a coherent game benefits more from refinement than from an unfinished extra system. ## Choose structure from the game | Game need | Useful implementation | Check in play | | --- | --- | --- | | Timing, aiming, movement | fixed-step motion, intentional input forgiveness, clear hit/hurt rules | latency, collision visibility, recovery | | Puzzle, cards, board decisions | legal moves, explicit turn/state transitions, undo/hints if appropriate | unambiguous interaction, solvability, no unintended deadlock | | Strategy or simulation | commands, selection, resource/progression model, bounded simulation work | dense-state readability, visible priorities, speed/pause behavior | | Exploration or narrative | interaction state, authored pacing, dialogue/progress | where to go next, readable text, interruption/resume | | Sandbox or endless play | sustainable loop, reset/pause, progression or player-created goals | continued agency and recovery rather than a forced win screen | These are examples, not required genres. Let a tile grid, ECS, physics engine, level editor or procedural generator earn its place. The starter demonstrates engine wiring; replace its platformer rules when the brief calls for another game. ## Iterate on a representative slice Build enough of the real experience to judge: the intended control, meaningful opposition/constraint, feedback and a consequence. Include the visual direction early so mechanics and presentation are evaluated together. Exercise it before multiplying levels, cards or enemies. For each pass, observe a specific problem, change its cause and repeat the same interaction. Examples: near-edge jump ignored → inspect input/collision timing; puzzle move appears ignored → show selection, legal targets and result; enemies blend into scenery → fix silhouette/value separation before adding particles. Avoid applying every game-feel effect to every game. Work backwards from the desired experience: predict which rule makes the player choose a particular behavior, then observe whether the slice produces it. For example, limited ammunition may encourage positioning or may merely add waiting; check which occurs before adding more weapons. Mechanics, resulting player behavior and felt experience are different claims. Effects cannot establish that a decision is interesting. This is a practical use of the [MDA framing](https://github.com/pluginagentmarketplace/custom-plugin-game-developer/blob/aa7edfe267b34eac63d888f60b13e08aca7850ed/skills/game-design-theory/SKILL.md), not a formula that guarantees enjoyment. ## Calibrate content to capabilities Before producing many encounters, establish the measurements they depend on: jump reach and recovery, attack/range limits, movement per turn, obtainable resources, time to complete a route, or readable choices on one screen. Make representative content with comfortable and demanding margins, then verify both. If a capability changes, identify and recheck the content whose assumptions changed. Test **reachability**, not just absence of crashes: a gap must be traversable, a required purchase must be affordable from reachable resources, a target score must be attainable, and a puzzle needs a legal solution. A known solution proves existence, not uniqueness or a good difficulty curve. Introduce a pattern in a readable situation before combining it with another demand; a tutorial label is not evidence the player learned it. These checks adapt the capability/content relationship in [Higgsfield's design system](https://github.com/higgsfield-ai/skills/blob/f83af0bc1d937c8119099a11f8ebbf5e6fb99819/higgsfield-websites/references/game-design-system.md). Where randomness matters, inspect both typical outcomes and damaging streaks; equal averages can produce different play. Where resources compound, examine whether an early advantage removes later decisions or a catch-up rule erases good play. Change the rule that produces the behavior, then replay the same scenario. Do not add currency, rewards or dynamic difficulty merely to fill a design checklist. ## Finish the presentation in context - Make the player, interactable object and immediate threat/goal identifiable at play scale. Decorative detail must not obscure information. - Use consistent spacing, alignment and text hierarchy across play, pause and outcomes. Check real strings, small windows and promised languages. Title and settings screens should serve the game rather than add mandatory ceremony. - Give actions a response on the next presented state. Match anticipation, impact and recovery to the action. Distinguish idle and selected states. - Use music/effects when they improve the requested experience. Check volume, overlap, abrupt loops and meaningful visual alternatives for audio cues. Follow project-specific audio/asset rules; preserve provenance and licences. - Choose AA/filtering deliberately: clean edges for smooth geometry and stable alignment for pixel art. Inspect motion for shimmer, seams and unreadable FX. - Introduce demands in a learnable order. Tune the first encounter and at least one later/high-pressure case; identical repeated content is not progression. Use fixed seeds or authored scenarios to reproduce difficult situations. ## Verify a complete session Use the same action layer for scripted and real input. For short games, exercise a whole session; for longer games, a representative route and important progression/save boundaries. | Evidence | Establishes | Does not establish | | --- | --- | --- | | Rules/transition tests | legal outcomes, reset, scoring, seeded reproduction where required | feel and clarity | | Scripted rendered run | reachability, visual states, reproducible failures | human input timing/comprehension | | Inspected screenshots | layout, contrast, missing art/glyphs | motion, frame pacing, audio | | Real play with audio | responsiveness, difficulty, sound balance and loops | other devices/platforms not exercised | | Cross-build | compile/link success for those flags | installation, SDK services, runtime behavior | Exercise new start, normal play, an invalid action, failure/retry and completion or sustained play. Include focus loss, resize and disconnect where relevant. For saves, test restart/load and malformed/old data; for generation, test seeds that stress the rules. Do not require a gamepad for a mouse/touch game or forced failure for a creative sandbox. If direct play/audio/device testing is unavailable, use the strongest available checks, inspect actual renders and state the gap accurately. Do not turn a PNG or test pass into a claim that the game is fun, balanced or listened to. Fix material observed problems before delivery. Keep a concise scenario/change/result record so the artifact and conclusions are reproducible.
references/gameplay-systems.md# Gameplay systems: tweens, animation, particles, juice, Tiled maps, physics
API baseline: Ebitengine v2.10.5. Advance state in `Update` and render it in
`Draw`. These snippets illustrate individual systems; validate their inputs and
test their integration in the actual game. Choose systems based on the
[game design and quality criteria](design-and-quality.md).
Contents: [Tweens and easing](#tweens-and-easing) · [Animation state](#animation-state) · [Particles](#particles) · [Screen shake and hit-stop](#screen-shake-and-hit-stop) · [Tiled maps](#tiled-maps) · [Rigid-body physics (Chipmunk2D)](#rigid-body-physics-chipmunk2d)
## Tweens and easing
Count ticks, not seconds: a tween then plays identically at any frame rate and
in headless runs.
```go
type Ease func(t float64) float64
func Linear(t float64) float64 { return t }
func OutCubic(t float64) float64 { u := 1 - t; return 1 - u*u*u }
func InOutCubic(t float64) float64 {
if t < 0.5 {
return 4 * t * t * t
}
u := -2*t + 2
return 1 - u*u*u/2
}
func OutBack(t float64) float64 {
const c1 = 1.70158
const c3 = c1 + 1
u := t - 1
return 1 + c3*u*u*u + c1*u*u
}
// Tween moves one value from From to To over Ticks updates.
type Tween struct {
From, To float64
Ticks int
Ease Ease
tick int
}
// Step advances one tick and reports whether the tween has finished.
func (tw *Tween) Step() bool {
if tw.tick < tw.Ticks {
tw.tick++
}
return tw.tick >= tw.Ticks
}
func (tw *Tween) Value() float64 {
if tw.Ticks <= 0 {
return tw.To
}
t := float64(tw.tick) / float64(tw.Ticks)
if tw.Ease != nil {
t = tw.Ease(t)
}
return tw.From + (tw.To-tw.From)*t
}
```
Use `OutCubic` for things arriving (menus sliding in), `InOutCubic` for camera
moves between points, `OutBack` for a small overshoot on pop-ups and score
counters. Keep UI tweens short (8–20 ticks at 60 TPS). For several properties,
use one tween for a 0→1 progress and derive the properties from it.
## Animation state
`rendering.md` shows cutting frames from a sheet. A per-entity animator switches
clips without restarting the running one and holds the last frame of one-shot
clips:
Validate clips when loading: at least one frame and `Hold > 0`; call `Play`
before querying the animator. Keep the clip configuration immutable while used.
```go
type Clip struct {
Frames []*ebiten.Image
Hold int // ticks per frame
Loop bool // otherwise stays on the last frame
}
type Animator struct {
clip *Clip
tick int
}
// Play switches clips; replaying the current clip does not restart it.
func (a *Animator) Play(c *Clip) {
if a.clip != c {
a.clip, a.tick = c, 0
}
}
func (a *Animator) Update() { a.tick++ }
func (a *Animator) Done() bool {
return !a.clip.Loop && a.tick >= len(a.clip.Frames)*a.clip.Hold
}
func (a *Animator) Frame() *ebiten.Image {
i := a.tick / a.clip.Hold
if a.clip.Loop {
i %= len(a.clip.Frames)
} else if i >= len(a.clip.Frames) {
i = len(a.clip.Frames) - 1
}
return a.clip.Frames[i]
}
```
Choose the clip in `Update` from the rules state (`onGround`, `vx`, `hurtTicks`),
call `Play` every tick, and draw `Frame()` in `Draw`. Mirror for facing with
`GeoM.Scale(-1, 1)` around the actor's chosen registration pivot: translate that
pivot to the origin, apply the facing scale, then place the actor in the world.
Use the sprite centre only when it is the chosen pivot. See
[Assets and animation](assets-and-animation.md) for frame registration.
## Particles
A fixed-capacity pool, its own random generator (so effects never change the
gameplay sequence), no allocation per tick:
```go
type Particle struct {
X, Y, VX, VY float64
Life, Max int
Size float32
Color color.RGBA
}
type Particles struct {
ps []Particle
rng *rand.Rand // cosmetic generator, separate from gameplay
}
func NewParticles(capacity int, seed uint64) *Particles {
return &Particles{ps: make([]Particle, 0, capacity), rng: rand.New(rand.NewPCG(seed, 1))}
}
// Burst emits n particles; extra particles are dropped when the pool is full.
func (p *Particles) Burst(x, y float64, n int, speed float64, c color.RGBA) {
for i := 0; i < n && len(p.ps) < cap(p.ps); i++ {
a := p.rng.Float64() * 2 * math.Pi
s := speed * (0.3 + 0.7*p.rng.Float64())
life := 20 + p.rng.IntN(20)
p.ps = append(p.ps, Particle{X: x, Y: y, VX: math.Cos(a) * s, VY: math.Sin(a) * s,
Life: life, Max: life, Size: 2 + 2*p.rng.Float32(), Color: c})
}
}
func (p *Particles) Update() {
for i := 0; i < len(p.ps); {
q := &p.ps[i]
q.X += q.VX
q.Y += q.VY
q.VY += 0.15 // gravity
q.VX *= 0.96 // drag
q.Life--
if q.Life <= 0 {
p.ps[i] = p.ps[len(p.ps)-1]
p.ps = p.ps[:len(p.ps)-1]
continue
}
i++
}
}
func (p *Particles) Draw(dst *ebiten.Image, camX, camY float64) {
for _, q := range p.ps {
k := float32(q.Life) / float32(q.Max)
c := q.Color
c.A = uint8(float32(c.A) * k)
c.R, c.G, c.B = uint8(float32(c.R)*k), uint8(float32(c.G)*k), uint8(float32(c.B)*k) // premultiplied
vector.FillRect(dst, float32(q.X-camX)-q.Size/2, float32(q.Y-camY)-q.Size/2, q.Size, q.Size, c, true)
}
}
func (p *Particles) Len() int { return len(p.ps) }
```
Spawn bursts from the rules' events (`Landed`, `Coin`, `Hit`), not from inside
the rules. Colours are premultiplied, so fading scales R, G, B and A together.
Profile the expected particle count on the target devices. If individual shape
draws are a bottleneck, draw a pre-rendered small image per particle with
`DrawImage` or batch geometry with `DrawTriangles32` (its `uint32` indices are not
capped at 65,536 vertices like the `uint16` indices of `DrawTriangles`). The sample antialiases smooth
geometry; intentional pixel art needs a consistent snapped grid instead.
## Screen shake and hit-stop
Choose feedback by the event's meaning and frequency. A routine action should
leave headroom for a rare decisive one; a quiet puzzle may need no shake at all.
Treat each response as a bounded envelope that returns to rest. Repeated triggers
should extend/replace/accumulate according to one explicit policy, rather than
start competing tweens that each restore an old scale or pause value.
Emit feedback once from an accepted rules event, not every tick an attack is
held or every `Draw` while two bodies overlap. Keep visual squash, flashes and
camera offsets separate from collision state; gameplay knockback is a rules
effect and belongs in the simulation. Keep UI/input advancing during hit-stop,
and preserve intended buffered actions. See [Assets and animation](assets-and-animation.md)
for aligning contact markers, hitboxes and sound.
This applies the importance/decay principle from
`game-feel`.
There is no required number of channels. Easing is useful for settling UI or
poses; constant-speed projectiles, precise meters and some art directions should
stay linear. A large feedback bundle cannot compensate for unclear rules.
```go
// Juice holds presentation-only timers. Gameplay reads Frozen() to skip
// simulation ticks during hit-stop; Draw reads Offset() for the camera.
type Juice struct {
trauma float64 // 0..1, decays every tick
freeze int
tick int
}
func (j *Juice) Shake(amount float64) { j.trauma = math.Min(1, j.trauma+amount) }
func (j *Juice) HitStop(ticks int) { j.freeze = max(j.freeze, ticks) }
func (j *Juice) Frozen() bool { return j.freeze > 0 }
func (j *Juice) Update() {
j.tick++
if j.freeze > 0 {
j.freeze--
}
j.trauma = math.Max(0, j.trauma-1.0/30) // gone after half a second
}
// Offset is smooth noise scaled by trauma², so small hits barely move the
// screen and big ones shake hard. maxPx of 6-8 suits a 320-640 px screen.
func (j *Juice) Offset(maxPx float64) (float64, float64) {
s := j.trauma * j.trauma * maxPx
t := float64(j.tick)
return s * (math.Sin(t*1.7) + math.Sin(t*3.1)) / 2, s * (math.Sin(t*2.3+1) + math.Sin(t*4.3)) / 2
}
```
- In `Update`, sample `frozen := juice.Frozen()` **before** `juice.Update()`.
Then skip simulation if `frozen`; otherwise step rules and apply their new
hit-stop requests. This freezes exactly N subsequent simulation ticks for
`HitStop(N)`, including N=1. Continue sampling input and updating presentation;
buffer actions the game should preserve across the freeze.
- In `Draw`: add `juice.Offset(8)` to the camera position.
- Example starting values at 60 TPS: a heavy hit might use `HitStop(3–6)` and
`Shake(0.4–0.6)`. Tune by the game's pace and feedback hierarchy; many genres
need neither hit-stop nor shake.
- Offer a "reduce screen shake" option that scales `maxPx` to 0
(`text-and-ui.md`, accessibility).
## Tiled maps
For Tiled-based levels, read [the map-loader subset and integration notes](tilemaps.md).
This is optional; grids, authored Go data and other level formats are valid.
Keep collision/spawn data in the rules layer instead of querying rendered pixels.
## Rigid-body physics (Chipmunk2D)
For simple axis-aligned tile movement, the starter's `world` solver is a small,
testable starting point. Slopes, moving platforms, one-way surfaces and
continuous collisions need additional rules. Consider a physics engine for
stacks, ragdolls, ropes, vehicles and physics puzzles.
`github.com/jakecoffman/cp/v2` is a pure-Go port of Chipmunk2D:
```go
package game
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/jakecoffman/cp/v2"
)
// Rigid-body physics with Chipmunk2D's Go port. Units are your world
// units (pixels here); y grows downward like the screen, so gravity is +y.
type Physics struct {
Space *cp.Space
StepSeconds float64 // fixed positive simulation step; default assumes 60 TPS
}
func NewPhysics() *Physics {
s := cp.NewSpace()
s.SetGravity(cp.Vector{X: 0, Y: 600})
s.Iterations = 10
return &Physics{Space: s, StepSeconds: 1.0 / 60}
}
// AddGround adds a static segment (floors, walls, slopes).
func (p *Physics) AddGround(x0, y0, x1, y1 float64) {
seg := cp.NewSegment(p.Space.StaticBody, cp.Vector{X: x0, Y: y0}, cp.Vector{X: x1, Y: y1}, 2)
seg.SetFriction(0.8)
seg.SetElasticity(0.3)
p.Space.AddShape(seg)
}
func (p *Physics) AddBall(x, y, r, mass float64) *cp.Body {
b := p.Space.AddBody(cp.NewBody(mass, cp.MomentForCircle(mass, 0, r, cp.Vector{})))
b.SetPosition(cp.Vector{X: x, Y: y})
sh := p.Space.AddShape(cp.NewCircle(b, r, cp.Vector{}))
sh.SetFriction(0.6)
sh.SetElasticity(0.5)
return b
}
func (p *Physics) AddBox(x, y, w, h, mass float64) *cp.Body {
b := p.Space.AddBody(cp.NewBody(mass, cp.MomentForBox(mass, w, h)))
b.SetPosition(cp.Vector{X: x, Y: y})
sh := p.Space.AddShape(cp.NewBox(b, w, h, 0))
sh.SetFriction(0.7)
return b
}
// Update steps with the fixed tick length. Never pass a measured frame time.
func (p *Physics) Update() {
p.Space.Step(p.StepSeconds)
}
// DrawBody draws img centred on a body, rotated with it.
func DrawBody(dst, img *ebiten.Image, b *cp.Body, camX, camY float64) {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(-float64(w)/2, -float64(h)/2)
op.GeoM.Rotate(b.Angle())
pos := b.Position()
op.GeoM.Translate(pos.X-camX, pos.Y-camY)
op.Filter = ebiten.FilterLinear // rotated sprites look better filtered
dst.DrawImage(img, op)
}
```
- Initialize a positive fixed `StepSeconds` for the chosen simulation rate and
step once per simulation tick. Do not feed measured render-frame time into
this fixed-tick integration. Keep input/UI ticking if simulation is paused or
slowed. `SyncWithFPS` is not a positive fixed TPS.
- Choose consistent units and tune tolerances, gravity, timestep and solver
settings together. Numeric scale and extreme mass ratios affect stability;
a particular pixel size or mass is not a universal requirement.
- Collision callbacks (`space.NewCollisionHandler`) run inside `Step`; record
events there and react after the step, the same way as the tile rules.
- Choose rotated-sprite filtering to fit the art direction. Infinite moment
(`cp.NewBody` with `math.Inf(1)`) prevents angular acceleration from torque;
it is a gameplay constraint, not a required pixel-art technique.
- `github.com/solarlune/resolv` is a lighter alternative for collision
detection without dynamics (shapes, rays, intersection tests).
Source contract: [Chipmunk2D Go port](https://github.com/jakecoffman/cp).
references/input.md# Input
API baseline: Ebitengine v2.10.5. Snippets are patterns to adapt, with the
project's imports, initialization and validation.
## Rules
- Read input in `Update`, once per tick. `inpututil` tracks press and release
edges per tick and must not be called from `Draw`.
- Held state: `ebiten.IsKeyPressed`, `IsMouseButtonPressed`,
`IsStandardGamepadButtonPressed`. Edges: `inpututil.IsKeyJustPressed`,
`IsKeyJustReleased`, `IsMouseButtonJustPressed`,
`IsStandardGamepadButtonJustPressed`, `AppendJustPressedTouchIDs`.
- Multi-key constants such as `ebiten.KeyShift` work with `IsKeyPressed` but never
report "just pressed"; use `KeyShiftLeft`/`KeyShiftRight` for edges.
- Collect IDs with the `Append*` functions into a reused slice:
`pads = ebiten.AppendGamepadIDs(pads[:0])`. The slice-returning forms
(`GamepadIDs`, `TouchIDs`, `inpututil.PressedKeys`) are deprecated.
- Positions from `ebiten.CursorPosition`, `CursorPositionF` and `TouchPosition`
use the engine's default mapping to `Layout` coordinates. A custom
`DrawFinalScreen` transform also needs matching input conversion
(`rendering.md`).
## Actions instead of keys
Translate devices into an `Actions` value at the top of `Update` and let game code
read only actions. This keeps keyboard, gamepad and touch equivalent and makes
scripted input (tests, demos, replays) trivial: replace the device reader. The
starter's `input.go` is a complete example.
### Rebindable bindings
```go
type Action int
const (
ActLeft Action = iota
ActRight
ActJump
actCount
)
type Bindings struct {
Keys [actCount][]ebiten.Key
Buttons [actCount][]ebiten.StandardGamepadButton
}
func DefaultBindings() Bindings {
var b Bindings
b.Keys[ActLeft] = []ebiten.Key{ebiten.KeyArrowLeft, ebiten.KeyA}
b.Keys[ActRight] = []ebiten.Key{ebiten.KeyArrowRight, ebiten.KeyD}
b.Keys[ActJump] = []ebiten.Key{ebiten.KeySpace, ebiten.KeyZ}
b.Buttons[ActLeft] = []ebiten.StandardGamepadButton{ebiten.StandardGamepadButtonLeftLeft}
b.Buttons[ActRight] = []ebiten.StandardGamepadButton{ebiten.StandardGamepadButtonLeftRight}
b.Buttons[ActJump] = []ebiten.StandardGamepadButton{ebiten.StandardGamepadButtonRightBottom}
return b
}
// Held aggregates all bindings before deriving the action's press edge.
func (b *Bindings) Held(a Action, pads []ebiten.GamepadID) bool {
for _, k := range b.Keys[a] {
if ebiten.IsKeyPressed(k) {
return true
}
}
for _, id := range pads {
if !ebiten.IsStandardGamepadLayoutAvailable(id) {
continue
}
for _, btn := range b.Buttons[a] {
if ebiten.IsStandardGamepadButtonPressed(id, btn) {
return true
}
}
}
return false
}
type ActionState struct {
Held, Pressed [actCount]bool
}
// Sample runs once at the top of each Update, including while menus are open.
func (s *ActionState) Sample(b *Bindings, pads []ebiten.GamepadID) {
for a := Action(0); a < actCount; a++ {
held := b.Held(a, pads)
s.Pressed[a] = held && !s.Held[a]
s.Held[a] = held
}
}
// Rebind waits for the next key press and assigns it to the action.
// Call it from Update while a "press a key" prompt is shown.
func (b *Bindings) Rebind(a Action, buf []ebiten.Key) (done bool) {
buf = inpututil.AppendJustPressedKeys(buf[:0])
if len(buf) == 0 {
return false
}
b.Keys[a] = []ebiten.Key{buf[0]}
return true
}
```
This defines an action edge as up → down across all bindings: pressing a second
jump key while the first is held does not retrigger jump. If a game wants each
physical press to trigger, aggregate binding edges instead and document that
choice. Axis bindings need a dead zone and, for digital actions, a threshold.
Persist bindings with the save data (see `architecture-and-testing.md`). Ignore
the input that opened the rebind prompt, provide cancel/reset and conflict
handling, and re-sample held state when a binding changes. Show key names with
`ebiten.KeyName(key)`; it reflects the layout for named printable keys but may
return an empty string. Supply labels for arrows, modifiers and unsupported
platforms (for example a table, with `key.String()` as a final fallback).
For engine-neutral rebinding and remapping UX, see `input-systems`.
## Menus: key repeat
Holding a direction in a menu should move once, pause, then repeat.
`inpututil.KeyPressDuration` returns how many ticks the key has been held:
```go
func repeating(key ebiten.Key) bool {
d := inpututil.KeyPressDuration(key) // ticks held, 0 if up
return d == 1 || (d >= 20 && (d-20)%4 == 0)
}
```
`StandardGamepadButtonPressDuration` does the same for pads.
## Gamepads
- Prefer the standard layout (W3C "standard gamepad"): check
`ebiten.IsStandardGamepadLayoutAvailable(id)`, then use
`StandardGamepadButton*` (`RightBottom` is the bottom face button: A on Xbox,
Cross on PlayStation; `CenterRight` is Start/Options) and
`StandardGamepadAxisValue` (−1…1; −1 is left/up).
- Pads without a standard mapping expose raw `GamepadButton`/`GamepadAxisValue`
indices that differ per device; offer rebinding rather than guessing.
- Apply a dead zone to sticks:
```go
func stickX(id ebiten.GamepadID) float64 {
v := ebiten.StandardGamepadAxisValue(id, ebiten.StandardGamepadAxisLeftStickHorizontal)
const dead = 0.2
switch {
case v > dead:
return (v - dead) / (1 - dead)
case v < -dead:
return (v + dead) / (1 - dead)
}
return 0
}
```
- Hot-plugging: `inpututil.AppendJustConnectedGamepadIDs` and
`inpututil.IsGamepadJustDisconnected(id)`; pause the game when the active
player's pad disconnects. No IDs are reported before the game starts.
## Mouse and simple buttons
```go
type Button struct {
X, Y, W, H int
armed bool
}
func (b Button) Hover() bool {
x, y := ebiten.CursorPosition() // already in Layout's coordinates
return x >= b.X && x < b.X+b.W && y >= b.Y && y < b.Y+b.H
}
// Update activates only when the press began here and the release is inside.
func (b *Button) Update() bool {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
b.armed = b.Hover()
}
if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
clicked := b.armed && b.Hover()
b.armed = false
return clicked
}
return false
}
```
Reset `armed` when the UI loses focus or closes. Overlapping widgets need one
input owner; touch needs the same press-origin rule tied to a specific TouchID.
Keyboard/gamepad confirmation should activate the focused widget.
With a camera, convert the cursor with the inverse camera transform
(`rendering.md`). `ebiten.Wheel()` returns scroll offsets for this tick.
`ebiten.SetCursorMode(ebiten.CursorModeCaptured)` locks the cursor for mouse-look.
## Touch
`ebiten.AppendTouchIDs` lists current touches and `TouchPosition(id)` gives their
positions; `inpututil.AppendJustPressedTouchIDs` and `IsTouchJustReleased` give
edges; `TouchPressDuration` distinguishes taps from holds. Desktop builds report
no touches, and `CursorPosition` is always (0, 0) in mobile apps, so support both
paths if the game runs on both. Make touch targets at least about 44
device-independent pixels at the expected scale; 44 game pixels in a small
logical viewport can be much larger. Check targets on the target device.
## Text entry
- Short Latin input (names, codes): append `ebiten.AppendInputChars(buf[:0])`
each tick, handle Backspace with `repeating(ebiten.KeyBackspace)`.
- Japanese and other IME input: `github.com/hajimehoshi/ebiten/v2/exp/textinput`
supports composition, but it is experimental and its API is changing (v2.10
deprecates `Field` in favour of `Composer`). Follow `examples/textinput` for the
pinned version.
## Device prompts and cancellation
For games supporting several devices, derive prompts from the current binding
and meaningful recent input, not hardcoded default key names. Use dead zones
and a stability policy so stick drift or incidental mouse motion does not
continually swap the prompt set. Cancel held actions on the appropriate focus,
disconnect or scene boundary; never leave a touch/capture armed after its input
owner disappears. This cleans up transient held/armed/capture state, not semantic
selection: an intentionally selected card or inventory item may remain selected
across a settings modal. Reconcile held controls on return so the closing press
does not leak into gameplay. This adapts the prompt/state lessons in
[game-ui-design](https://github.com/omer-metin/skills-for-antigravity/blob/e8dcf4e8737921a10088bd5c9eb65e81f74c051f/skills/game-ui-design/references/patterns.md)
and [Three.js touch UI](https://github.com/majidmanzarpour/threejs-game-skills/blob/8286774b22a2566bf894dbc825825c16921866af/skills/threejs-game-ui-designer/references/ui-patterns.md).
Browser pointer-event names do not translate directly into Ebitengine APIs.
## Browsers
Keyboard events reach the game only while its canvas or iframe has focus. Ask the
player to click or tap the game first (a title screen does this naturally), and
avoid browser shortcuts (Ctrl+W, F5) as game controls.
Source contracts: [input APIs](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/input.go)
and [tick-based edge helpers](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/inpututil/inpututil.go).
references/monetization.md# Ads, purchases and store services Read when the user requests these integrations. Ebitengine has no built-in ad or billing SDK. Native mobile hosts and browser pages connect their platform SDKs to Go. A game should not acquire ads, purchases or an online backend merely because this reference exists. ## Pick the integration actually needed | Distribution | Common integration | | --- | --- | | Desktop paid download | storefront handles checkout; game-side SDK only for selected features such as DLC or achievements | | Browser portal | portal JavaScript adapter for required lifecycle/ad APIs | | Android/iOS | native host with billing/ad SDK, bridged to the Go game | | Offline/test build | explicit unavailable/fake service, preserving the intended base game | Verify current rules for the target store, region and program, including billing, restoration, privacy and child-directed games. Do not hardcode universal policy claims: the [Google Play payments policy](https://support.google.com/googleplay/android-developer/answer/9858738) and [Apple review guidelines](https://developer.apple.com/app-store/review/guidelines/) have conditions and change over time. Keep policy/legal choices outside generic engine code. ## Game-side contract Use an asynchronous service boundary. Give operations request IDs and purchases transaction IDs; queue results for `Update` and apply each grant at most once. A useful result distinguishes reward earned, ad dismissed, unavailable, cancelled, pending, purchased and failed. Do not treat dismissal as reward or a submitted purchase as an entitlement. For ads: - Rewarded and interstitial are different operations. Give the adapter separate methods; never implement interstitial by calling a rewarded-ad method. - Pause gameplay and audio while covered, but keep the service/event pump alive. Aggregate pause reasons so closing an ad does not undo a user's pause or app-background state. - Correlate callbacks with the active request. Deduplicate reward notifications, ignore stale results and support reward/close callbacks in either documented order. A timeout must recover UI without inventing a reward; define how late valid rewards are reconciled. - Handle no-fill, load failure, cancellation and scene exit. The unavailable build must return a result, not leave a permanent loading overlay. - Place interstitials at appropriate breaks and show rewarded ads only after a player action that explains the reward. Follow the portal/network's actual lifecycle requirements. For purchases: - Validate and reconcile entitlements using the platform's documented flow. Deduplicate transactions, persist grants and handle pending/deferred states, refunds/revocation and restore/reinstall as applicable. - Finish/acknowledge transactions only according to the SDK's delivery rules. Separate a durable entitlement from a consumable grant. - Test repeated callbacks and restart between payment and grant. A fake `Purchased` callback test does not establish a working store purchase. ## Native mobile bridge Put a small bindable API in the package passed to `ebitenmobile bind` (the package that calls `mobile.SetGame`). Native methods dispatch SDK/UI work to the host's UI thread and return promptly; native callbacks post results to a synchronized Go inbox. `Update` drains the inbox. Avoid accessing game state from callbacks. `gomobile` supports a restricted API subset; keep the boundary to simple strings, booleans, supported numeric/byte values and bindable interfaces. Keep game-only structs, channels and service implementations outside the exported surface. Generate bindings with the pinned Ebitengine/gomobile toolchain and inspect the actual generated Java/Objective-C API rather than assuming names or that a Go build proves bindability. Follow [Ebitengine mobile setup](https://ebitengine.org/en/documents/mobile.html). Install native services before use or synchronize access explicitly. The host must also forward lifecycle/pause and persist progress before background termination; normal game shutdown is not guaranteed on mobile. ## Browser adapter Keep a platform-specific `//go:build js && wasm` file for `syscall/js` calls and a JavaScript adapter tied to the selected portal's current SDK. The adapter normalizes its actual callbacks/promises into the game-side results above. - Check both adapter existence and method type. Validate callback arguments. - `js.FuncOf` callbacks must return promptly; do not block them on a promise. Track callback ownership and release functions only after JS will no longer call them. Handle exceptions, missing callbacks and cancellation explicitly. - Match separate rewarded/interstitial methods and lifecycle hooks. Test with the real portal's sandbox as well as an unavailable/fake adapter. - After ads, restore audio according to gesture/browser policy and the player's mute setting, and restore input focus. Do not assume autoplay permission alone unlocks audio. ## Acceptance tests Use a fake service with delayed results to test: no fill; close without reward; reward before/after close; duplicate reward; stale result for a prior request; scene exit; background plus ad pause; timeout and late result; repeated purchase; pending purchase; restore; restart after durable grant. Then run actual sandbox ads/purchases on each promised platform. Record which of these were executed; Go compilation or generated bindings alone do not verify an SDK integration.
references/networking.md# Networking and multiplayer Read only for requested multiplayer work. Ebitengine supplies the game loop, not a network protocol, matchmaking or authoritative server. Go libraries can provide transport; browser builds have different transport constraints. Keep rules independent of rendering so clients, tests and a server can share them. This reference is design guidance, not a ready-to-ship netcode implementation. A toy relay or same-process determinism test is insufficient evidence for an online game. Add executable protocol tests in the actual project. ## Choose the model from latency and trust requirements | Need | Candidate | Main cost | | --- | --- | --- | | Local play | several devices/actions in one simulation | device ownership and join/leave flow | | Turn-based or asynchronous moves | server validates moves over HTTP/WebSocket | reconnect, persistence, turn ownership | | Synchronized deterministic simulation | lockstep, exchanging inputs | peers wait for missing input; determinism and latency budget | | Responsive deterministic action | rollback with prediction and state restore | full state capture, re-simulation, cosmetic event reconciliation | | Many players or authoritative competition | server authority with prediction/interpolation | server operations, correction and interpolation | These are choices, not genre rules. Online play need not start as a local multiplayer product. A local two-peer harness is useful for testing the chosen protocol. Establish player count, acceptable delay, join/reconnect behavior and hosting before expanding content that depends on them. ## Fixed simulation and determinism - Keep the simulation tick distinct from Ebitengine's presentation/update clock. When lockstep stalls, continue sampling input, polling messages and drawing; do not block `Update` waiting for a peer. - For cross-architecture bit identity, integers/fixed point simplify positions and velocities. Go permits fused floating-point operations and architecture implementations can differ; do not infer cross-platform determinism from two copies in one process. See the [Go spec](https://go.dev/ref/spec#Floating_point_operators). - Stable iteration order matters: sort map keys or use ordered storage for simulation. Separate gameplay and cosmetic RNGs; serialize all gameplay RNG state for rollback. Fixed seeds alone do not control input order or threading. - Version the rules, content and protocol. Exchange hashes at agreed simulation ticks, compare states at the same tick, and retain inputs/seed/version to reproduce a desync. Test across the supported architectures. ## Lockstep protocol invariants Use a fixed session roster and input delay selected from measured latency/jitter. Three to six ticks is only 50–100 ms at 60 TPS; it does not cover every internet connection. Seed initial delayed ticks consistently. For each simulation tick: 1. Sample local actions and schedule them for the appropriate future tick. Send each tick's immutable input once. Waiting for remote input must not repeatedly overwrite or resend a different value for the same local tick. 2. Drain received messages from a bounded inbox. Validate session, authenticated sender/player mapping, tick window, input bits and duplicate consistency. 3. Advance only when the roster's inputs for the current tick are complete. Release consumed history according to the replay/rollback policy. 4. If a peer stalls/disconnects, apply an explicit timeout/reconnect/end policy while menus and status remain responsive. Reject invalid player indices before indexing, bound future/history windows and queue sizes, and reject conflicting duplicates. A peer-supplied `Player` field alone is not identity. A reliable ordered transport does not supply a session handshake, late join, state synchronization or authority. ## Transport and lifecycle `github.com/coder/websocket` supports Go desktop and `js/wasm`; browsers cannot use Go's raw TCP/UDP APIs. Pin and inspect the transport version and its [documentation](https://pkg.go.dev/github.com/coder/websocket). HTTPS pages need secure WebSocket endpoints. Use only transports supported by the chosen targets. - Dial, read and write outside `Update`/`Draw`. Queue immutable commands and results. Blocking writes with `context.Background()` can freeze a game even for two players; use deadlines, bounded queues and explicit overflow/error policy. Surface send failures to the game. - Have one owner for connection lifecycle and a defined writer order. Cancel workers on scene/session exit. In browser `js.Func` callbacks, return promptly; do not wait synchronously for promises/events that need the JS event loop. - A relay needs a roster/ready barrier before play. Enforce message size/rate, room capacity and origin policy, and isolate slow peers with bounded output queues. Define disconnect/empty-room cleanup. - Validate moves on the authoritative server where the game requires trust. Relaying input does not prevent cheating. SDK bindings such as Steamworks do not imply that matchmaking/transport APIs needed by the game are exposed. ## Private views and session lifecycle Construct each player's authorized view on the server **before serialization**. Hiding cards, fog-of-war entities or private resources in the client's renderer does not hide them from that client. Send a full authorized view on join/resync; if using diffs, define the base/version and recovery after a missed update. Replication relevance can reduce traffic, but is also an information boundary. For matchmaking, a connected-player count is not enough: concurrent joins can consume the same free slot. Serialize allocation and count pending reservations; give reservations an expiry and bind one-use join credentials to the assigned session/player. Recheck on connect, release failed/expired reservations and reject stale assignments after cancellation/requeue. Apply a completed match result idempotently so retries cannot award a second score/rating update. These are transferable view/lifecycle lessons from [multiplayer-game](https://github.com/rivet-dev/skills/blob/ba5d3db3d7489cfc6190fd0de45b96e3787e1ea3/multiplayer-game/SKILL.md) and [Higgsfield's game flow](https://github.com/higgsfield-ai/skills/blob/f83af0bc1d937c8119099a11f8ebbf5e6fb99819/higgsfield-websites/references/game-flow.md). They apply when those online features are promised; they do not require Rivet, sharding or a backend for a local game. Turn-based servers can apply discrete actions without a continuous realtime loop; Ebitengine can still animate the UI at its normal update cadence. ## Verification before claiming online support Test two real peers, then scripted latency, jitter, stalls, disconnects, duplicate, invalid and out-of-window messages. Check UI responsiveness and bounded memory, not just state equality. Exercise handshake, version mismatch, reconnect and shutdown. For rollback, verify restored RNG/timers and that speculative sounds, particles and rewards are reconciled rather than duplicated. For an authoritative server, test input validation, prediction correction and snapshot loss/reordering. Build browser clients, then run them over HTTP with the actual relay. A successful WASM build is not a connection test. Record the tested platforms, protocol/library versions and scenarios; leave untested deployment/scale claims explicit.
references/playtest-diagnostics.md# Observe interactions, not just screenshots Use when automating play, reproducing a bug or checking an important multi-step feature. The idea is a short sequence of actions, controlled advancement, observation and a focused change, adapted from [develop-web-game](https://github.com/openai/skills/blob/30444aed500c00c85294d12074f6e3ee794f808a/skills/.curated/develop-web-game/SKILL.md). Its JavaScript hooks and Playwright client are not Ebitengine APIs. ## Define a scenario with intermediate outcomes Start from a known state/seed. Use the game's action dispatcher, with explicit press/hold/release and tick counts. Observe intermediate states before issuing the next action, so a successful final score does not hide a broken transition. | Example chain | Observe along the way | | --- | --- | | Collect key → open door → reach exit | inventory changes once, locked/unlocked feedback agrees, next level starts with intended state | | Hit → damage → defeat → reward | one contact event, correct health, target stops acting, one reward | | Pause → settings → back → resume | underlying play frozen as intended, controls owned by active UI, focus restored, no leaked confirm/action | | Invalid puzzle move → undo/reset → solve → replay | refusal explained, state/history restored, completion reachable, replay has no stale result | Choose chains relevant to the requested change. Reset between independent scenarios: world, RNG, timers, buffered input, pending scene transitions and cosmetic events may all matter. Reuse the same scenario after the fix; add a different boundary case only when it addresses an unresolved concern. ## Pair rendered evidence with actual state For a scripted failure, a compact read-only diagnostic snapshot can include: game/content version, seed, tick, scene/mode, logical coordinate origin and units, camera/viewport, relevant positions, resources, timers, active UI/focus and the last action/event. Derive it from real state; do not build a second simulation or print expected answers. Keep it local/debug-only, concise, and free of credentials or other players' secret state. Capture the state and image for the **same tick/scene**. On a catch-up frame, logging in every `Update` and later capturing in `Draw` can pair different moments. Label the drawn capture with its state tick; report if the requested tick was not drawn. Compare both: a JSON health value can be correct while the visible meter is stale, and a correct-looking frame can hide a rule failure. The starter supplies `-demo` and `-shot`; it does **not** supply a state-JSON or general input-script flag. Add a small project-specific diagnostic when needed, or use the pinned engine's opt-in VM tooling described in [Architecture/testing](architecture-and-testing.md). A scripted action dispatch tests rules/flow; actual device or VM input also tests the device-to-action path. Run graphics checks under Xvfb on Linux, not the user's desktop. ## Control time without changing the game being checked Advance the real fixed-step simulation rather than sleeping for an estimated number of updates. Keep test stepping separate from the normal scheduling loop so both cannot advance the same state. A capture-only `SyncWithFPS` mode may accelerate tick-based rules, but does not measure interactive frame pacing, audio synchronization or wall-clock services. Never transplant a browser `advanceTime` hook by calling `Game.Update` concurrently with `RunGame`. Inspect runtime errors as well as images/state. Fix the first new causal failure before collecting downstream symptoms. Record the initial state, action/tick sequence, changed behavior and inspected artifacts. Test only the affected paths plus relevant regression boundaries; do not rebuild every platform after each text or color adjustment without a reason.
references/project-and-builds.md# Project setup and builds
The bundled starter pins Ebitengine v2.10.5 / Go 1.25+. Keep an existing
project's pinned versions. Commands are POSIX-shell examples; adapt to the
selected targets and record actual build/runtime verification separately.
## Requirements
- **Go 1.25 or later** for Ebitengine v2.10 (`go.mod` of v2.10 says `go 1.25.0`).
Older Ebitengine releases support older Go versions; keep a project's pinned
pair unless upgrading on purpose.
- **Ebitengine desktop builds are pure Go in v2.10**: the engine needs no C
compiler or development headers on Windows, macOS or Linux. Other dependencies
(including native SDKs) may add requirements.
- **Linux at run time** needs an X server (or XWayland), OpenGL and, for sound,
PulseAudio/PipeWire or ALSA. Debian/Ubuntu:
`sudo apt install libx11-6 libgl1 libglx-mesa0`, optionally
`libxcursor1 libxi6 libxinerama1 libxrandr2 libxrender1 libxext6`, and
`libasound2t64` (Debian 13, Ubuntu 24.04+) or `libasound2` (older).
Fedora: `sudo dnf install libX11 libglvnd-glx mesa-libGL mesa-dri-drivers`.
- **WSL**: run the Windows build, `GOOS=windows go run .`.
- **Mobile** builds still use cgo and the platform SDKs (below).
- Smoke test of a machine: `go run github.com/hajimehoshi/ebiten/v2/examples/rotate@v2.10.5`.
## New project
```sh
mkdir mygame && cd mygame
go mod init example.com/mygame
go get github.com/hajimehoshi/ebiten/v2@v2.10.5
```
or `python3 "$skill_dir/scripts/new_game.py" --out mygame --module example.com/mygame`, then
`go mod tidy`. Commit `go.sum`. Upgrade deliberately (`go get
github.com/hajimehoshi/ebiten/v2@vX.Y.Z`), then run
the checker from this skill's absolute path and the tests.
## Assets
Embed bundled assets for consistent loading across targets (user saves and
downloadable content need separate storage). For example:
```go
//go:embed assets
var assets embed.FS
```
Read with `assets.ReadFile("assets/player.png")`. Embedding adds to binary and
`.wasm` size; compress music as Ogg Vorbis, keep effects short.
## Window and screen
Call before `RunGame` (most are also safe at run time):
| Call | Purpose |
| --- | --- |
| `ebiten.SetWindowTitle("…")` | title bar text |
| `ebiten.SetWindowSize(w, h)` | initial size in device-independent pixels, usually a multiple of the logical size |
| `ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)` | let the player resize; `Layout` chooses logical dimensions |
| `ebiten.SetWindowIcon([]image.Image{...})` | window icon (several sizes) |
| `ebiten.SetFullscreen(b)` / `IsFullscreen()` | toggle, e.g. on F11 or Alt+Enter |
| `ebiten.SetVsyncEnabled(b)` | on by default; choose a deliberate release pacing policy |
| `ebiten.SetTPS(n)` | logic rate, 60 by default |
| `ebiten.RunGameWithOptions(g, &ebiten.RunGameOptions{...})` | transparent window, start unfocused, graphics library, single-thread mode |
On Windows, Ebitengine hides the console window of a game started from Explorer
once it is running. Release builds still add `-H=windowsgui` so no console
appears at all (see the Windows section of `publishing-and-platforms.md`).
## Desktop builds
```sh
go build -buildvcs=false -trimpath -ldflags="-s -w" -o build/mygame . # this OS
GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags="-s -w -H=windowsgui" -o build/mygame.exe .
GOOS=darwin GOARCH=arm64 go build -buildvcs=false -trimpath -ldflags="-s -w" -o build/mygame-macos-arm64 .
GOOS=darwin GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags="-s -w" -o build/mygame-macos-amd64 .
GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags="-s -w" -o build/mygame-linux .
```
Steam builds for Windows add `-X=runtime.godebugDefault=asyncpreemptoff=1` to
`-ldflags` (`publishing-and-platforms.md`).
For universal macOS distribution, an `.app` bundle is useful: `scripts/package_macos_app.py`
assembles one from the amd64 and arm64 binaries. Unsigned apps need the user to
allow them in System Settings; signing and notarisation need Apple tooling on a
Mac (`publishing-and-platforms.md`).
## Browser (WebAssembly)
Quick test (serves and rebuilds on reload):
```sh
go run github.com/hajimehoshi/wasmserve@latest .
# open http://localhost:8080/
```
Publishing:
```sh
GOOS=js GOARCH=wasm go build -buildvcs=false -trimpath -ldflags="-s -w" -o dist/game.wasm .
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/ # same Go version as the build
```
`dist/index.html`:
```html
<!DOCTYPE html>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("game.wasm"), go.importObject).then(r => go.run(r.instance));
</script>
```
- Serve over HTTP (a `file://` page cannot fetch the `.wasm`). The server must
send `application/wasm` for `instantiateStreaming`.
- Embed the game page in another page with
`<iframe src="index.html" width="1280" height="720" allow="autoplay"></iframe>`.
- Audio starts only after a user gesture; keyboard input needs focus (click first).
- `wasm_exec.js` must match the Go version used to build; it lives in
`$(go env GOROOT)/lib/wasm/` for current Go releases.
- No ordinary persistent OS file system: small saves can use `localStorage` via
`syscall/js`; handle quota/unavailable storage. Larger data may need IndexedDB.
- For itch.io upload, zip `dist/` with `index.html` at the top level and test the actual hosted page.
For page setup and butler uploads, see `itch-publish`.
## Mobile
Install the binder and create a mobile package that registers the game with
`mobile.SetGame` (not `RunGame`) and exports at least one function:
```sh
go install github.com/hajimehoshi/ebiten/v2/cmd/ebitenmobile@v2.10.5
```
```go
package mygamemobile
import (
"github.com/hajimehoshi/ebiten/v2/mobile"
"example.com/mygame/game"
)
func init() { mobile.SetGame(game.New()) }
// Dummy is exported because gomobile skips packages without exported functions.
func Dummy() {}
```
```sh
ebitenmobile bind -target android -javapkg com.example.mygame -o mygame.aar ./mobile
ebitenmobile bind -target ios -o MyGame.xcframework ./mobile
```
The Android `.aar` provides an `EbitenView`; call its `suspendGame()`/`resumeGame()`
from `onPause`/`onResume`. The iOS framework provides a view controller to
subclass. Requirements: Android SDK/NDK via Android Studio (ebitenmobile defaults
to minimum API 21; `-androidapi` sets it), Xcode
for iOS 13+. For this layout, move the game type out of `package main` into a
package (here `game`) that both `main` and the mobile package import.
## Release checklist
- [ ] Run relevant rules/static checks; inspect advisory checker findings.
Linux tests that initialize graphics run under `xvfb-run -a`.
- [ ] Headless frame capture looks right; one manual play-through per platform.
- [ ] Promised resize/fullscreen/pause/quit behavior and persistent saves work.
- [ ] Every advertised input method supports its intended play/menu flow; browser
build tested for focus and sound after the first input.
- [ ] Licence files for fonts, music and art ship with the build.
- [ ] Version stamped (`-ldflags "-X main.version=…"`, shown on the title or
options screen) so bug reports name a build.
- [ ] Distribution: [platforms](publishing-and-platforms.md). Automated builds:
[CI](ci-and-release.md). Test the final archive after extraction.
Sources: [v2.10 installation/runtime requirements](https://ebitengine.org/en/documents/install.html),
[WebAssembly](https://ebitengine.org/en/documents/webassembly.html),
[mobile setup](https://ebitengine.org/en/documents/mobile.html).
references/publishing-and-platforms.md# Publishing and platforms Read for requested packaging/distribution work. These recipes target the v2.10.5 starter. Ebitengine's pure-Go desktop backend does not make every game's SDKs/dependencies portable or establish runtime/store compatibility. Build commands: [Project setup](project-and-builds.md). Automation: [CI and releases](ci-and-release.md). ## Target selection | Target | Engine path | Additional verification | | --- | --- | --- | | Windows/macOS/Linux | `go build` for supported GOOS/GOARCH | runtime libraries, game dependencies, actual launch/input/audio | | Browser | `GOOS=js GOARCH=wasm` | HTTP host, matching `wasm_exec.js`, browser console/focus/audio | | Android/iOS | `ebitenmobile bind` plus native host | cgo, SDK/NDK or Xcode, native lifecycle, real devices | | Steam | desktop build, optional Steamworks SDK | platform packaging, client services and Valve review | | Consoles | licensed platform-specific port/toolchain | developer access, SDK, development kit and certification | Only promise targets actually requested and checked. Public build tags are not proof of available or complete console support. Consult the pinned engine's [README](https://github.com/hajimehoshi/ebiten) and platform holder for current access; keep SDK-specific material within its permitted environment. ## Windows ```sh GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath \ -ldflags="-s -w -H=windowsgui" -o build/mygame.exe . ``` `-H=windowsgui` avoids a startup console. Provide useful diagnostics through a log file/error UI when release console output is unavailable. Embed an icon with a resource tool (its `.syso` is linked by Go) or call `SetWindowIcon`; v2.10 also uses executable icons as window icons. Code signing and SmartScreen behavior require testing with the actual distributed binary. For **Windows Steam builds**, the current [Ebitengine Steam guide](https://ebitengine.org/en/documents/steam.html) recommends `-X=runtime.godebugDefault=asyncpreemptoff=1` for reported freezes. This disables asynchronous preemption; keep it a documented target-specific workaround, not an unconditionally harmless flag on every build. Recheck the engine/Go guidance when upgrading. ## macOS The packager can assemble a universal unsigned `.app`: ```sh CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -buildvcs=false -trimpath -o build/mygame_amd64 . CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -buildvcs=false -trimpath -o build/mygame_arm64 . python3 "$skill_dir/scripts/package_macos_app.py" --name "My Game" \ --bundle-id com.example.mygame --version 1.2.0 \ --amd64 build/mygame_amd64 --arm64 build/mygame_arm64 --out build ``` Inspect `--help` for icons and version options. Select the minimum OS consistent with the actual Go/dependency toolchain. The packager reads Mach-O deployment targets and rejects a lower explicit minimum. Packaging on Linux does not verify launch, signing or notarization. For distribution, follow [Apple's notarization workflow](https://developer.apple.com/documentation/Security/customizing-the-notarization-workflow) and the chosen store's requirements. Sign the final bundle on macOS with the appropriate identity, hardened runtime and required entitlements. Steam has specific signing/entitlement requirements in [Valve's platform documentation](https://partner.steamgames.com/doc/store/application/platforms). Submit, staple and validate before archiving; for example use `ditto -c -k --keepParent` to create the final zip. Extract the distributed archive on a clean Mac and check launch and Gatekeeper, not just the original local bundle. The supplied packager and CI do not perform these steps. ## Linux ```sh CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -o build/mygame . ``` Pure-Go builds still load graphics/window/audio libraries at runtime. See the [official installation guide](https://ebitengine.org/en/documents/install.html) for the pinned engine version. Test with the target distribution/runtime; building under a development desktop does not prove a clean-machine launch. For Steam/Deck, test the selected native runtime or Proton path and supported controllers, text scale and overlay behavior. ## Steamworks boundary [`go-steamworks`](https://github.com/hajimehoshi/go-steamworks) binds some of the Steamworks flat API with purego. Pin a concrete version and inspect its exposed functions and platform support; it does not automatically provide all Steam features. The binding can load its shared library during package initialization, so isolate the import behind a store/platform build tag. A runtime flag after `init` cannot rescue a build whose SDK library failed to load. For depots, SteamPipe uploads and store release, see `steam-publish`. Keep game-used services behind a small interface and provide a non-Steam implementation for other targets/tests. Initialize the SDK deliberately, call `RunCallbacks` while the game is running, and shut it down according to its lifecycle. Handle offline/unavailable services without claiming a save or an achievement was synchronized. Retry/reconcile according to API results. Use the real App ID, registered achievement names and depots for release. The official Ebitengine guide describes the required Windows Steam DLL. Development `steam_appid.txt` and test credentials must not leak into release packaging. Test through the Steam client, including offline/restart/cloud conflicts where those features are promised. A binding compile test does not validate these. Steam Auto-Cloud may avoid a custom cloud-save implementation; choose based on the game's needs and Valve's current documentation. ## Browser, mobile and port readiness Browser packaging needs `index.html` at the archive root, the matching Go `wasm_exec.js` and correct WASM HTTP serving. Test actual hosting, loading errors, focus, aspect ratios and audio after a gesture. Portal SDK requirements differ; implement only the selected portal's integration (see [Monetization](monetization.md)). Mobile needs a native host around the `.aar`/`.xcframework`. Forward lifecycle, save before background termination and test touch/safe areas/resume on devices. Store submission, signing and purchases live in that host/store workflow. For a later port, separate storage and platform services; keep fonts/assets available and rules independent of direct OS access. Support the port's intended controls through menus and text entry. Use logical layouts suited to the game and destination, rather than imposing one fixed resolution on every game. Release acceptance comes from the destination's current requirements and actual execution, not from this portability checklist.
references/rendering.md# Rendering
API baseline: Ebitengine v2.10.5. Snippets illustrate individual patterns; add the
imports, initialization and validation required by the project.
## Images are GPU resources
- Create reusable images at load or when a level starts. Repeated `NewImage`,
`NewImageFromImage` and decoding in hot paths can cause allocation and upload
costs; profile before attributing a slowdown to them.
- Ebitengine packs small images into shared atlases automatically. Do not build
your own atlas just for batching; do keep each sprite sheet as one image and cut
frames with `SubImage`.
- Reading pixels back (`At`, `ReadPixels`) waits for the GPU. Use it for
screenshots and tests only. `ReadPixels` cannot run before `RunGame` starts.
- Do not draw the screen into another image, do not draw A into B and B into A,
and do not change an image's pixels after using it as a source in the same
frame: each forces extra work.
### Decode embedded PNGs once
```go
//go:embed assets/*.png
var assetsFS embed.FS
func loadImage(path string) (*ebiten.Image, error) {
b, err := assetsFS.ReadFile(path)
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(b)) // import _ "image/png"
if err != nil {
return nil, err
}
return ebiten.NewImageFromImage(img), nil
}
```
## Transforms
`GeoM` operations apply in the order you call them. To rotate or scale around a
sprite's centre: move the centre to the origin, scale, rotate, then place.
```go
func drawSprite(dst, img *ebiten.Image, x, y, angle, scale float64) {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(-float64(w)/2, -float64(h)/2) // 1. pivot to origin
op.GeoM.Scale(scale, scale) // 2. scale
op.GeoM.Rotate(angle) // 3. rotate (radians)
op.GeoM.Translate(x, y) // 4. place in the world
dst.DrawImage(img, op)
}
```
Options can be reused in a loop; reset every field that varies per sprite,
including `GeoM` and `ColorScale`. A local options value often stays on the
stack, so creating one per draw does not itself prove a heap allocation.
## Sprite sheets and animation
```go
type Anim struct {
frames []*ebiten.Image
ticksPerFrame int
}
func NewAnim(sheet *ebiten.Image, frameW, frameH, count, ticksPerFrame int) *Anim {
a := &Anim{ticksPerFrame: ticksPerFrame}
for i := 0; i < count; i++ {
x := i * frameW
// SubImage shares the sheet's pixels; frames batch together when drawn.
a.frames = append(a.frames, sheet.SubImage(image.Rect(x, 0, x+frameW, frameH)).(*ebiten.Image))
}
return a
}
// Frame picks the frame for a tick counted in Update; Draw only reads it.
func (a *Anim) Frame(tick int) *ebiten.Image {
return a.frames[(tick/a.ticksPerFrame)%len(a.frames)]
}
```
Leave at least one transparent pixel between frames in the sheet if you ever
draw with `FilterLinear` or non-integer scales; neighbouring frames otherwise
bleed in at the edges.
Validate frame dimensions, count and ticks per frame at load time;
`Anim.Frame` assumes non-empty frames and a positive tick duration.
## Colour and blending
`ColorScale` multiplies premultiplied colour: `ScaleAlpha(a)` fades the whole
sprite. `Blend` chooses compositing (`ebiten.BlendLighter` for additive light).
`DrawImageOptions.ColorM` and `CompositeMode` are deprecated since v2.5; use
`ColorScale`, `Blend`, or the `colorm` package for full colour matrices.
```go
func drawGlow(dst, img *ebiten.Image, x, y float64, alpha float32) {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(x, y)
op.ColorScale.Scale(1, 0.8, 0.4, 1) // warm tint
op.ColorScale.ScaleAlpha(alpha) // fade (premultiplied: scales RGB too)
op.Blend = ebiten.BlendLighter // additive
dst.DrawImage(img, op)
}
```
## Shapes
`vector.FillRect`, `StrokeRect`, `StrokeLine`, `FillCircle`, `StrokeCircle` take
`float32` coordinates, a colour and an `antialias` flag; the `Stroke*` functions
also take a `float32` `strokeWidth` before the colour. Use `true` for smooth geometry; intentional
pixel-art edges may use `false`. (v2.9 renamed
`DrawFilledRect`/`DrawFilledCircle` to `FillRect`/`FillCircle`). For other
shapes, build a `vector.Path`:
```go
func drawStar(dst *ebiten.Image, cx, cy, r float32) {
var p vector.Path
for i := 0; i < 10; i++ {
rad := r
if i%2 == 1 {
rad = r * 0.45
}
a := float64(i)*math.Pi/5 - math.Pi/2
x, y := cx+rad*float32(math.Cos(a)), cy+rad*float32(math.Sin(a))
if i == 0 {
p.MoveTo(x, y)
} else {
p.LineTo(x, y)
}
}
p.Close()
op := &vector.DrawPathOptions{AntiAlias: true}
op.ColorScale.Scale(1, 0.85, 0.2, 1)
vector.FillPath(dst, &p, nil, op)
}
```
Shapes that never change are cheaper drawn once into an image at load and then
drawn as a sprite.
## Camera
Keep game objects in world coordinates and convert in `Draw`.
```go
type Camera struct {
X, Y float64 // world point at the screen centre
Zoom float64 // initialize to 1; must stay positive for inverse mapping
}
func (c Camera) Apply(geo *ebiten.GeoM, screenW, screenH float64) {
geo.Translate(-c.X, -c.Y)
geo.Scale(c.Zoom, c.Zoom)
geo.Translate(math.Round(screenW/2), math.Round(screenH/2))
}
// ScreenToWorld converts a cursor position (already in logical pixels).
func (c Camera) ScreenToWorld(sx, sy, screenW, screenH float64) (float64, float64) {
var g ebiten.GeoM
c.Apply(&g, screenW, screenH)
g.Invert()
return g.Apply(sx, sy)
}
```
Per sprite: `op.GeoM.Reset()`, translate to the sprite's world position, then
`op.GeoM.Concat(cameraGeoM)`. For pixel art without zoom, round the camera
position once per frame and add it to integer sprite positions, so everything
moves by whole pixels together; rounding each sprite separately makes them
jitter against each other.
Smooth following belongs in `Update` (move a fixed fraction of the distance per
tick and clamp to the level bounds), as in the starter's `playScene`.
## Tilemaps
Compute the visible cell range from the camera and draw only those cells; skip
empty cells. Draw all tiles from one tileset image in one loop so they batch.
For static layers, consider caching visible-sized chunks. A whole-map image
uses width × height × 4 bytes before additional GPU costs and can exceed device
texture limits; use it only when its measured size and load cost are acceptable.
## Layers and offscreens
An offscreen is an `ebiten.NewImage(w, h)` you `Clear()` and draw into each
frame, then draw onto the screen (for lighting, a minimap, a pixelated layer
under smooth UI). Create it once; recreate it only when its size must change.
## Screen size, scaling and HiDPI
`Layout` decides the logical screen. Three common choices:
| Goal | `Layout` returns | Notes |
| --- | --- | --- |
| Pixel art | a fixed small size, e.g. 320×180 | choose a viewport/sampling policy below |
| Fixed-size design that scales smoothly | a fixed size, e.g. 1280×720 | letterboxed; `FilterLinear` for art |
| UI that uses all the space | the outside size | layout code must handle any size |
Start with the default final-screen renderer. In v2.10.5 it uses nearest
sampling at integer scales, `FilterPixelated` for fractional enlargement, and
linear filtering for shrinking. This preserves the engine's matching input
transform. Test the actual window sizes and device scale factors you support.
Strict integer enlargement requires a deliberate viewport policy. If using
`FinalScreenDrawer`, retain its supplied default matrix **D** and your custom
presentation matrix **P**. Cursor and touch positions returned by Ebitengine
still use D; convert them with `P⁻¹(D(position))` before hit testing, and reject
points in the letterbox bars. Test clicks after resizing and on HiDPI displays.
Define the behavior when the physical window is smaller than the logical
screen: forcing a minimum scale of 1 crops the game instead of fitting it.
Do not paste a custom draw-only scaling function into an input-driven game.
For sharp text and vectors on HiDPI displays, implement `LayoutF` and multiply by
the device scale factor (then draw with coordinates scaled by the same factor):
```go
func (g *Game) LayoutF(outsideW, outsideH float64) (float64, float64) {
s := ebiten.Monitor().DeviceScaleFactor()
return outsideW * s, outsideH * s
}
```
`ebiten.DeviceScaleFactor()` is deprecated since v2.6; use
`ebiten.Monitor().DeviceScaleFactor()`.
## Performance checklist
1. Count draw commands: build with `-tags=ebitenginedebug` and read the log.
Many commands per frame usually means interleaved source images, blends or
filters or texture-atlas boundaries. Group compatible draws only when the
resulting order preserves overlap and transparency.
2. Show `ebiten.ActualFPS()` and `ebiten.ActualTPS()` with
`ebitenutil.DebugPrint` during development.
3. Reuse hot-path slices (`buf[:0]`), images and measured allocation-heavy
resources. Choose a frame-time/allocation budget from profiling rather than
enforcing zero allocations everywhere.
4. Profile CPU with `runtime/pprof` around a scripted run before guessing.
5. `ebiten.SetVsyncEnabled(false)` can help measure uncapped rendering cost;
retain an intentional frame-pacing policy for the release.
For the chosen target, set a frame-time budget and exercise representative dense
content, transitions and first-use asset/glyph loads. Track slow frames and load
spikes as well as average FPS. A 60 Hz presentation interval is about 16.67 ms;
that is a chosen target, not a universal requirement. CPU duration of `Draw` is
not GPU completion time, and uncapped hidden capture is not a pacing test.
If profiling identifies expensive noncritical AI/path queries, consider spreading
them over fixed simulation ticks with a stable entity-based phase. Do not skip
collision-critical movement, tie rules updates to `Draw`, or let offscreen render
culling silently stop gameplay. These are conditional applications of
[game-developer's performance guidance](https://github.com/Jeffallan/claude-skills/blob/02b05028ba6c93f7aed45314b15c0dd5abead9d6/skills/game-developer/references/performance-optimization.md),
rather than requirements to add pooling, LOD or an ECS before measuring.
For engine-neutral profiling and budgeting, pair this with `performance-optimization`.
For art-set/sprite registration checks, see [Assets and animation](assets-and-animation.md).
For Kage, consult the pinned engine's shader documentation or its
`writing-kage-shaders` skill when available.
Source contracts: [default final-screen renderer](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/gameforui.go),
[input coordinate conversion](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/internal/ui/context.go),
and [performance guidance](https://ebitengine.org/en/documents/performancetips.html).
references/text-and-ui.md# Text and UI
API baseline: Ebitengine v2.10.5, package
`github.com/hajimehoshi/ebiten/v2/text/v2`. Snippets illustrate patterns to adapt;
they are not a complete text-layout or localization library.
The font examples use outline fonts. Keep a suitable existing bitmap font or
glyph-atlas renderer when it fits the art and shipped scripts; these examples
do not require replacing it.
Contents: [Fonts](#fonts) · [Drawing and alignment](#drawing-and-alignment) · [Wrapping](#wrapping) · [Readability](#readability) · [Simple UI](#simple-ui) · [UI states](#ui-states-attention-and-ownership) · [Localization](#localization) · [Accessibility](#accessibility)
## Fonts
- Parse each font file once with `text.NewGoTextFaceSource` and keep the source.
It owns the glyph cache. Create `&text.GoTextFace{Source: src, Size: px}` values
freely; they are cheap.
- Embed the font file (`//go:embed fonts/MyFont.ttf`) and keep its licence file
next to it. `golang.org/x/image/font/gofont/goregular` (BSD licence) is a
convenient font for prototypes; check glyph coverage for the shipped strings.
- Japanese, Chinese and Korean need a font that contains those glyphs. For
prototypes, `github.com/hajimehoshi/ebiten/v2/examples/resources/fonts`
exposes `MPlus1pRegular_ttf` (M+ licence, free use); for a shipped game, embed
your own CJK font such as one licensed under the SIL OFL, and include the licence.
- Glyph images are made per size. Animating `Size` every frame regenerates glyphs;
to zoom text, draw it once at the largest size into an image and scale the image.
- `text.CacheGlyphs(str, face)` pre-renders glyphs (for example all digits for a
score) to avoid a hitch the first time they appear.
```go
type Fonts struct {
Latin *text.GoTextFaceSource
}
func LoadFonts() (*Fonts, error) {
src, err := text.NewGoTextFaceSource(bytes.NewReader(goregular.TTF))
if err != nil {
return nil, err
}
return &Fonts{Latin: src}, nil
}
func (f *Fonts) Face(size float64) *text.GoTextFace {
return &text.GoTextFace{Source: f.Latin, Size: size}
}
```
### Mixed scripts
`text.NewMultiFace` tries faces in order per glyph, so a Latin face with a CJK
fallback renders both in one string:
```go
func mixedFace(latin, cjk *text.GoTextFaceSource, size float64) (text.Face, error) {
return text.NewMultiFace(
&text.GoTextFace{Source: latin, Size: size},
&text.GoTextFace{Source: cjk, Size: size},
)
}
```
Set `GoTextFace.Language` (for example `language.Japanese` from
`golang.org/x/text/language`) when glyph forms differ by language. `Script` is
deprecated since v2.9.
## Drawing and alignment
`text.Draw(dst, str, face, op)` places the text's layout box at the origin of
`op.GeoM`. `op.PrimaryAlign` aligns along the line (start, centre, end) and
`op.SecondaryAlign` across lines, both relative to that origin. `op.LineSpacing`
is the distance between baselines and is required for multi-line strings. Colour
the text with `op.ColorScale.ScaleWithColor(c)`.
- `text.Measure(str, face, lineSpacing)` returns the width and height of a block.
- `text.AdvanceAt(str, len(str), face)` is the width of one line (v2.10
deprecates `text.Advance`).
- `face.Metrics()` gives `HAscent`, `HDescent` and `HLineGap`; their sum is a
natural line spacing.
## Wrapping
`text.Draw` respects explicit newlines; it does not automatically wrap to a
width. Compute lines when text, font, locale or available width changes, then
draw the cached result:
```go
// lines have already been wrapped by the project's text-layout code.
func drawParagraph(dst *ebiten.Image, lines []string, face text.Face, x, y float64, clr color.Color) {
m := face.Metrics()
lineH := m.HAscent + m.HDescent + m.HLineGap
op := &text.DrawOptions{}
op.GeoM.Translate(x, y)
op.LineSpacing = lineH
op.ColorScale.ScaleWithColor(clr)
text.Draw(dst, strings.Join(lines, "\n"), face, op)
}
```
A wrapper should choose legal line-break opportunities for the shipped
languages, measure each complete candidate with the actual face, and specify an
overflow policy for a word or glyph wider than the box. Space splitting alone
does not handle CJK; splitting every rune breaks combining marks and emoji
sequences. Use Unicode line-break and grapheme segmentation, including Japanese
kinsoku rules when shipping Japanese. Test long words, explicit blank lines,
mixed scripts, combining accents and narrow boxes. Use a proven layout library
for broad script support rather than inventing a code-point threshold.
## Readability
- Size text for the logical screen and the expected window scale: in a 320×180
pixel-art game, 8–12 px bitmap-style fonts read well; outline fonts under about
12 logical px blur after scaling. Use a larger logical screen or a separate
high-resolution UI layer for long text.
- Put a dark band or outline behind text over busy backgrounds; draw an outline by
drawing the text 4–8 times offset by 1 px in the outline colour, then once on top.
- Typewriter dialogue: advance revealed grapheme clusters in `Update`, not UTF-8
bytes. A rune alone can be half of a visible character. Keep wrapping stable
using the complete line, and check shaping for joined scripts; reshaping a
prefix can change its appearance.
## Simple UI
- Buttons and menus: rectangles hit-tested with the logical cursor (see
`input.md`), a focus index moved by keys/pad for non-mouse play, and the
same "confirm" action for click, Enter and the pad's bottom face button.
- Lay UI out in logical coordinates relative to the screen edges you get from
`Layout`, so it survives window resizes when `Layout` returns a fixed size.
- `ebitenutil.DebugPrint`/`DebugPrintAt` draws a fixed debug font; use it for
development overlays, not shipped UI.
- `github.com/ebitengine/debugui` (a separate module) provides immediate-mode
debug panels (sliders, checkboxes) for tuning during development.
## UI states, attention and ownership
For each element, name the decision it supports and when it is needed. Keep
urgent information visible when the threat exists; delay/coalesce less urgent
messages instead of letting a loot notification obscure the next action.
Optional details may appear contextually, but provide a way to recover an
important instruction. Distinguish unavailable, temporarily cooling down,
selected and active states, including why an attempted action was refused.
These are useful attention-management patterns from
[game-ui-design](https://github.com/omer-metin/skills-for-antigravity/blob/e8dcf4e8737921a10088bd5c9eb65e81f74c051f/skills/game-ui-design/references/patterns.md),
not reasons to impose a combat HUD on every game.
For advertised keyboard/gamepad paths, define initial focus, neighbors and back
behavior. A modal owns input; closing it restores a still-valid previous control
or a deliberate fallback. Skip hidden/disabled items, and prevent the same press
that opens/closes an overlay from also activating a control or gameplay behind
it. Mouse hover and persistent focus need not be the same state. This develops
the focus lifecycle in `game-ui-ux` (its layout-and-flow reference).
Reserve stable space for changing numbers (score, time, ammunition); measure the
largest supported representation or use suitable tabular digits. Test transitions
such as 9→10 and 99→100, long localized labels, disabled actions and simultaneous
alerts. This prevents layout movement at the moment attention is most occupied,
as emphasized by [Three.js game UI patterns](https://github.com/majidmanzarpour/threejs-game-skills/blob/8286774b22a2566bf894dbc825825c16921866af/skills/threejs-game-ui-designer/references/ui-patterns.md).
Its CSS choices and aesthetic dislikes are not engine or genre requirements.
The UI reads an authoritative model and dispatches intents rather than duplicating
legality/scoring rules. In Ebitengine, reading a small value in `Draw` is often
appropriate; cache costly formatting/wrapping or use change events when useful.
If using subscriptions, manage their lifetime on scene exit. Do not introduce a
global event bus simply to forbid harmless polling.
## Localization
For games that will ship in multiple languages, establish the catalog and text
layout before UI content grows. A small single-language prototype need not build
a localization system before testing its core mechanic.
- Every player-visible string goes through a catalog keyed by a stable ID; no
literals in `Draw`. Keep the source language as the fallback so a missing
translation shows English (or the key) instead of crashing.
- Embed a font for every shipped script (`text.NewMultiFace` above) and set
`GoTextFace.Language` per locale; Japanese and Chinese share code points but
not glyph shapes.
- Measure translated content instead of relying on a fixed expansion percentage;
allow flexible button widths and test the longest shipped strings.
- Use whole sentences with placeholders (`"%d coins"`), never concatenate
fragments; word order differs between languages. Plurals differ too: use
`golang.org/x/text/message` or separate keys when counts matter.
- Pick the initial locale from the store or system, then let the player change
it in options and save the choice.
```go
// i18n.go
package game
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"strings"
"golang.org/x/text/language"
)
// Strings live in one JSON file per locale (assets/i18n/en.json, ja.json),
// keyed by stable IDs, never by the English text:
//
// {"menu.start": "Start", "hud.coins": "Coins: %d"}
type Catalog struct {
lang string
msgs map[string]string
fallback map[string]string // the source language, for missing keys
}
func LoadCatalog(fsys fs.FS, dir, lang, fallback string) (*Catalog, error) {
read := func(l string) (map[string]string, error) {
b, err := fs.ReadFile(fsys, dir+"/"+l+".json")
if err != nil {
return nil, err
}
m := map[string]string{}
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
return m, nil
}
fb, err := read(fallback)
if err != nil {
return nil, err
}
c := &Catalog{lang: lang, msgs: fb, fallback: fb}
if lang != fallback {
if m, err := read(lang); err == nil {
c.msgs = m
} else {
c.lang = fallback
}
}
return c, nil
}
func (c *Catalog) Lang() string { return c.lang }
// T formats a message. Missing keys fall back to the source language, then
// to the key itself, so a missing string is visible but never crashes.
func (c *Catalog) T(key string, args ...any) string {
s, ok := c.msgs[key]
if !ok {
if s, ok = c.fallback[key]; !ok {
s = key
}
}
if len(args) == 0 {
return s
}
return fmt.Sprintf(s, args...)
}
// PickLocale matches what the platform reports (Steam language name, the
// browser's navigator.language, $LANG) against the locales the game ships.
func PickLocale(reported string, supported []language.Tag) language.Tag {
if len(supported) == 0 {
return language.Und // callers should validate shipped locales at startup
}
steam := map[string]string{"english": "en", "japanese": "ja", "schinese": "zh-Hans",
"tchinese": "zh-Hant", "koreana": "ko", "french": "fr", "german": "de",
"spanish": "es", "latam": "es-419", "brazilian": "pt-BR", "russian": "ru"}
if t, ok := steam[reported]; ok {
reported = t
}
reported = strings.SplitN(reported, ".", 2)[0] // ja_JP.UTF-8 -> ja_JP
reported = strings.ReplaceAll(reported, "_", "-")
tag, _ := language.Parse(reported) // errors leave language.Und
m := language.NewMatcher(supported)
_, i, _ := m.Match(tag)
return supported[i]
}
// DesktopLocale reads the usual environment variables (Linux, macOS
// terminals). Windows and macOS apps should prefer the store's language or a
// setting in the options menu.
func DesktopLocale() string {
for _, k := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
if v := os.Getenv(k); v != "" && v != "C" && v != "POSIX" {
return v
}
}
return ""
}
```
Browser builds read the language from JavaScript
(`js.Global().Get("navigator").Get("language").String()`) in a `//go:build js`
file; Steam builds can obtain the reported language through their store adapter
([Publishing/platforms](publishing-and-platforms.md)).
## Accessibility
Options that cost little in Ebitengine and widen the audience:
- **Remapping** required actions for the supported devices (`input.md`), including
one-handed layouts where practical. Provide alternatives for required rapid
tapping or simultaneous presses ("hold to repeat", toggle instead of hold).
- **Text size**: a UI scale option (draw UI into a layer scaled by 1–2×), and a
minimum readable size for the target screen (`Readability` above).
- **Colour**: never encode meaning by colour alone; add shapes, icons or
patterns. Check palettes with a colour-blindness simulator.
- **Motion and flashes**: options to reduce screen shake (scale the offset to 0,
`gameplay-systems.md`) and disable flashing effects. A frequency rule alone
does not establish flash safety; brightness, contrast and affected screen area
also matter. Avoid unnecessary high-contrast flashes.
- **Audio**: separate music, effects and voice volumes; captions or on-screen
cues for sounds that carry information.
- **Difficulty and timing**: consider assist modes, adjustable time limits and
pausing where the genre permits. Keep UI and input responsive during slow
motion: use an explicit simulation-time scale rather than lowering `SetTPS`.
Lower TPS changes input/UI cadence, does not slow audio, and does not slow a
physics step that computes `1/TPS`. Online games require an agreed pause policy.
- **Input devices**: verify a complete playthrough with each advertised device.
A keyboard-only puzzle and a touch-only rhythm game need different support;
avoid making mouse controls mandatory in an advertised gamepad path.
Collect these in an options scene and save them with the save data.
Choose the relevant options from the game's supported inputs and intended audience;
see [Design and quality](design-and-quality.md).
Source contracts: [text layout and measurement](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/text/v2/text.go)
and [font source and size behavior](https://github.com/hajimehoshi/ebiten/blob/v2.10.5/text/v2/gotext.go).
references/tilemaps.md# Tiled maps
API baseline: Ebitengine v2.10.5. Read only when the game uses Tiled.
The illustrative loader below handles a constrained subset; prefer an existing
maintained loader when the map requires more of Tiled's format.
## Finite orthogonal JSON subset
[Tiled](https://www.mapeditor.org/) is the usual level editor. Save maps as JSON
(`.tmj`, tilesets as `.tsj` or embedded), layer format CSV or Base64 with zlib or
gzip. The loader below handles finite orthogonal maps, tile and object layers,
external tilesets, margins/spacing and the flip flags in the top bits of each
global tile ID (horizontal `0x80000000`, vertical `0x40000000`, diagonal
`0x20000000`, hexagonal rotation `0x10000000`). Clear the flags with `&^`, then
the tileset is the one with the largest `firstgid` not above the ID.
Its supported subset is deliberately small: non-grouped tile/object layers with
no layer offsets, parallax or tint; a single atlas image per tileset; square
tiles matching the map grid; and point or axis-aligned rectangle objects.
It does not implement tile animation, tile properties, image-collection
tilesets, rotated/tile/polygon objects, or nested groups. The checks below
reject those cases instead of silently displaying a different level. Use an
established loader or explicitly extend this subset when the project needs more.
```go
package game
import (
"bytes"
"compress/gzip"
"compress/zlib"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"image"
"io"
"io/fs"
"path"
"github.com/hajimehoshi/ebiten/v2"
)
// Minimal loader for Tiled JSON maps (.tmj): orthogonal, finite, tile and
// object layers, embedded or external (.tsj) tilesets, CSV or base64 data
// (uncompressed, zlib or gzip). Field names follow the Tiled JSON format.
const (
flipH = 0x80000000
flipV = 0x40000000
flipD = 0x20000000
rotHex = 0x10000000
gidFlags = flipH | flipV | flipD | rotHex
)
type TiledMap struct {
Width, Height int
TileWidth, TileHeight int `json:"-"`
Orientation string
Infinite bool
Layers []TiledLayer
Tilesets []TiledTileset
}
type TiledLayer struct {
Name, Type string
Visible bool
Opacity float64
Width, Height int
Data json.RawMessage
Encoding, Compression string
Objects []TiledObject
X, Y float64
OffsetX, OffsetY float64
ParallaxX, ParallaxY *float64
TintColor, Mode string
gids []uint32
}
type TiledObject struct {
Name, Type, Class string // Tiled 1.9 used class; 1.10+ uses type again
X, Y, Width, Height float64
Point bool
GID uint32
Rotation float64
Ellipse, Capsule bool
Polygon, Polyline []json.RawMessage
Text json.RawMessage
Template string
}
type TiledTileset struct {
FirstGID int `json:"firstgid"`
Source string
Image string
ImageWidth int `json:"imagewidth"`
Columns, TileCount int
Margin, Spacing int
TileWidth, TileHeight int `json:"-"`
Tiles []json.RawMessage
TileOffset struct{ X, Y int }
TransparentColor string
img *ebiten.Image
}
// UnmarshalJSON handles the lowercase keys Tiled writes for tile sizes.
func (m *TiledMap) UnmarshalJSON(b []byte) error {
type plain TiledMap
var aux struct {
plain
TW int `json:"tilewidth"`
TH int `json:"tileheight"`
}
if err := json.Unmarshal(b, &aux); err != nil {
return err
}
*m = TiledMap(aux.plain)
m.TileWidth, m.TileHeight = aux.TW, aux.TH
return nil
}
func (t *TiledTileset) UnmarshalJSON(b []byte) error {
type plain TiledTileset
var aux struct {
plain
TW int `json:"tilewidth"`
TH int `json:"tileheight"`
}
if err := json.Unmarshal(b, &aux); err != nil {
return err
}
*t = TiledTileset(aux.plain)
t.TileWidth, t.TileHeight = aux.TW, aux.TH
return nil
}
// Tiled's opacity defaults to 1 when omitted.
func (l *TiledLayer) UnmarshalJSON(b []byte) error {
type plain TiledLayer
aux := plain{Visible: true, Opacity: 1}
if err := json.Unmarshal(b, &aux); err != nil {
return err
}
*l = TiledLayer(aux)
return nil
}
// LoadTiledMap reads a .tmj from fsys (for example an embed.FS) and decodes
// its tileset images with decode (for example via image.Decode).
func LoadTiledMap(fsys fs.FS, name string, decode func(io.Reader) (image.Image, error)) (*TiledMap, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, err
}
var m TiledMap
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
if m.Orientation != "orthogonal" || m.Infinite {
return nil, fmt.Errorf("%s: only finite orthogonal maps are supported", name)
}
if m.Width <= 0 || m.Height <= 0 || m.TileWidth <= 0 || m.TileWidth != m.TileHeight {
return nil, fmt.Errorf("%s: need positive map dimensions and square grid tiles", name)
}
dir := path.Dir(name)
for i := range m.Tilesets {
ts := &m.Tilesets[i]
base := dir
if ts.Source != "" { // external tileset: keep firstgid from the map
first := ts.FirstGID
src := path.Join(dir, ts.Source)
tb, err := fs.ReadFile(fsys, src)
if err != nil {
return nil, err
}
if err := json.Unmarshal(tb, ts); err != nil {
return nil, fmt.Errorf("%s: %w", src, err)
}
ts.FirstGID, base = first, path.Dir(src)
}
if ts.Image == "" || ts.FirstGID <= 0 || ts.Columns <= 0 || ts.TileCount <= 0 ||
ts.Margin < 0 || ts.Spacing < 0 || ts.TileWidth != m.TileWidth || ts.TileHeight != m.TileHeight ||
len(ts.Tiles) != 0 || ts.TileOffset.X != 0 || ts.TileOffset.Y != 0 || ts.TransparentColor != "" {
return nil, fmt.Errorf("tileset %d: need a matching atlas without per-tile metadata or offset", i)
}
f, err := fsys.Open(path.Join(base, ts.Image))
if err != nil {
return nil, err
}
img, err := decode(f)
f.Close()
if err != nil {
return nil, fmt.Errorf("%s: %w", ts.Image, err)
}
rows := (ts.TileCount + ts.Columns - 1) / ts.Columns
needW := 2*ts.Margin + ts.Columns*ts.TileWidth + (ts.Columns-1)*ts.Spacing
needH := 2*ts.Margin + rows*ts.TileHeight + (rows-1)*ts.Spacing
if img.Bounds().Dx() < needW || img.Bounds().Dy() < needH {
return nil, fmt.Errorf("%s: atlas is too small for its declared tiles", ts.Image)
}
ts.img = ebiten.NewImageFromImage(img)
}
for i := range m.Layers {
l := &m.Layers[i]
if (l.Type != "tilelayer" && l.Type != "objectgroup") ||
l.X != 0 || l.Y != 0 || l.OffsetX != 0 || l.OffsetY != 0 ||
(l.ParallaxX != nil && *l.ParallaxX != 1) || (l.ParallaxY != nil && *l.ParallaxY != 1) ||
l.TintColor != "" || (l.Mode != "" && l.Mode != "normal") {
return nil, fmt.Errorf("layer %q: unsupported type or presentation properties", l.Name)
}
for _, o := range l.Objects {
if o.GID != 0 || o.Rotation != 0 || o.Ellipse || o.Capsule || len(o.Polygon) != 0 || len(o.Polyline) != 0 ||
len(o.Text) != 0 || o.Template != "" {
return nil, fmt.Errorf("object %q: only points and axis-aligned rectangles are supported", o.Name)
}
}
if l.Type == "tilelayer" {
if err := l.decode(); err != nil {
return nil, fmt.Errorf("layer %q: %w", l.Name, err)
}
for _, gid := range l.gids {
id := int(gid &^ gidFlags)
if id == 0 {
continue
}
ts := m.tileset(id)
if ts == nil || id-ts.FirstGID >= ts.TileCount {
return nil, fmt.Errorf("layer %q: invalid GID %d", l.Name, id)
}
}
}
}
return &m, nil
}
func (l *TiledLayer) decode() error {
if l.Width <= 0 || l.Height <= 0 {
return fmt.Errorf("tile layer dimensions must be positive")
}
count := l.Width * l.Height
if l.Encoding == "" || l.Encoding == "csv" {
if err := json.Unmarshal(l.Data, &l.gids); err != nil {
return err
}
if len(l.gids) != count {
return fmt.Errorf("got %d cells, want %d", len(l.gids), count)
}
return nil
}
if l.Encoding != "base64" {
return fmt.Errorf("encoding %q not supported", l.Encoding)
}
var s string
if err := json.Unmarshal(l.Data, &s); err != nil {
return err
}
raw, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return err
}
var r io.Reader = bytes.NewReader(raw)
var closer io.Closer
switch l.Compression {
case "":
case "zlib":
if r, err = zlib.NewReader(r); err != nil {
return err
}
closer = r.(io.Closer)
case "gzip":
if r, err = gzip.NewReader(r); err != nil {
return err
}
closer = r.(io.Closer)
default:
return fmt.Errorf("compression %q not supported (save as CSV, zlib or gzip)", l.Compression)
}
if closer != nil {
defer closer.Close()
}
raw, err = io.ReadAll(io.LimitReader(r, int64(count)*4+1))
if err != nil {
return err
}
if len(raw) != count*4 {
return fmt.Errorf("got %d tile bytes, want %d", len(raw), count*4)
}
l.gids = make([]uint32, count)
return binary.Read(bytes.NewReader(raw), binary.LittleEndian, l.gids)
}
// GID returns the raw global tile ID (with flip flags) at a cell, 0 if empty.
func (l *TiledLayer) GID(x, y int) uint32 {
if x < 0 || y < 0 || x >= l.Width || y >= l.Height {
return 0
}
return l.gids[y*l.Width+x]
}
func (m *TiledMap) tileset(id int) *TiledTileset {
var ts *TiledTileset
for i := range m.Tilesets { // the tileset with the largest firstgid <= id
if m.Tilesets[i].FirstGID <= id && (ts == nil || m.Tilesets[i].FirstGID > ts.FirstGID) {
ts = &m.Tilesets[i]
}
}
return ts
}
// tile finds the image for a validated GID; nil for empty cells.
func (m *TiledMap) tile(gid uint32) *ebiten.Image {
id := int(gid &^ gidFlags)
if id == 0 {
return nil
}
ts := m.tileset(id)
local := id - ts.FirstGID
x := ts.Margin + (local%ts.Columns)*(ts.TileWidth+ts.Spacing)
y := ts.Margin + (local/ts.Columns)*(ts.TileHeight+ts.Spacing)
return ts.img.SubImage(image.Rect(x, y, x+ts.TileWidth, y+ts.TileHeight)).(*ebiten.Image)
}
// DrawLayer draws the visible part of a tile layer with the camera at
// (camX, camY), honouring Tiled's flip flags.
func (m *TiledMap) DrawLayer(dst *ebiten.Image, l *TiledLayer, camX, camY float64) {
if !l.Visible {
return
}
tw, th := m.TileWidth, m.TileHeight
sw, sh := dst.Bounds().Dx(), dst.Bounds().Dy()
x0, y0 := int(camX)/tw, int(camY)/th
op := &ebiten.DrawImageOptions{}
op.ColorScale.ScaleAlpha(float32(l.Opacity))
for y := y0; y <= y0+sh/th+1; y++ {
for x := x0; x <= x0+sw/tw+1; x++ {
gid := l.GID(x, y)
img := m.tile(gid)
if img == nil {
continue
}
op.GeoM = TileGeoM(gid, float64(tw), float64(th))
op.GeoM.Translate(float64(x*tw)-camX, float64(y*th)-camY)
dst.DrawImage(img, op)
}
}
}
// TileGeoM maps a w×h tile into its cell at the origin, applying Tiled's
// flags in Tiled's order: diagonal (swap x and y) first, then horizontal,
// then vertical. Diagonal flips assume square tiles.
func TileGeoM(gid uint32, w, h float64) ebiten.GeoM {
var g ebiten.GeoM
g.Translate(-w/2, -h/2) // flip around the tile centre
if gid&flipD != 0 {
var swap ebiten.GeoM
swap.SetElement(0, 0, 0)
swap.SetElement(0, 1, 1)
swap.SetElement(1, 0, 1)
swap.SetElement(1, 1, 0)
g.Concat(swap)
}
if gid&flipH != 0 {
g.Scale(-1, 1)
}
if gid&flipV != 0 {
g.Scale(1, -1)
}
g.Translate(w/2, h/2)
return g
}
```
Use it with an embedded directory and `image.Decode` (import `_ "image/png"`):
```go
//go:embed maps
var maps embed.FS
m, err := LoadTiledMap(maps, "maps/level1.tmj", func(r io.Reader) (image.Image, error) {
img, _, err := image.Decode(r)
return img, err
})
```
- Collision: read a layer named `collision` into the
rules package's grid at load time; do not test collisions against images.
- Spawns, triggers, doors: object layers. Points have `point: true`; tile
objects use `y` at the bottom edge, rectangles at the top edge.
- Infinite maps store chunks instead of `data`; turn "Infinite" off in Tiled or
extend the loader. zstd compression needs an extra package; pick zlib.
- Large static layers: consider bounded chunk caches instead of a whole-map GPU
image (`rendering.md`).
- `github.com/lafriks/go-tiled` is a fuller loader that reads the XML `.tmx`
format.
Source contracts: [Tiled JSON format](https://doc.mapeditor.org/en/stable/reference/json-map-format/),
[Tiled GID flags](https://doc.mapeditor.org/en/stable/reference/global-tile-ids/).
scripts/check_ebitengine.py#!/usr/bin/env python3
"""Scan an Ebitengine (v2) Go project for deprecated APIs and common game-loop mistakes.
This is a heuristic source scan, not a Go type checker or a quality verdict.
It recognises named/aliased imports, but not dot imports or variable shadowing;
it scans every selected file regardless of build constraints. Review warnings.
Reads Go sources and go.mod only and never modifies them. Heuristic findings
are warnings or informational notes: deprecated does not mean broken. Ambiguous
At calls are notes and never fail --strict. Use go build/vet for errors.
The deprecation table matches
Ebitengine v2.10; on an older pinned version some replacements may not exist yet
(each finding names the version that deprecated the API).
Exit status: 0 = no errors, 1 = errors found (or warnings with --strict),
2 = bad arguments, unreadable target, or no Ebitengine v2 project signal.
Examples:
python3 check_ebitengine.py .
python3 check_ebitengine.py ./cmd/mygame --strict
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
TOOL = "check_ebitengine"
SCHEMA = 1
# (pattern, message). Patterns run on code with comments and string literals removed.
DEPRECATED = [
(r"\bebitenutil\.DrawRect\(", "ebitenutil.DrawRect (v2.5) -> vector.FillRect"),
(r"\bebitenutil\.DrawLine\(", "ebitenutil.DrawLine (v2.5) -> vector.StrokeLine"),
(r"\bebitenutil\.DrawCircle\(", "ebitenutil.DrawCircle (v2.5) -> vector.FillCircle"),
(r"\bebitenutil\.OpenFile\(", "ebitenutil.OpenFile (v2.4) -> os.Open, or go:embed"),
(r"\bvector\.DrawFilledRect\(", "vector.DrawFilledRect (v2.9) -> vector.FillRect"),
(r"\bvector\.DrawFilledCircle\(", "vector.DrawFilledCircle (v2.9) -> vector.FillCircle"),
(r"\bebiten\.SetMaxTPS\(", "ebiten.SetMaxTPS (v2.4) -> ebiten.SetTPS"),
(r"\bebiten\.MaxTPS\(", "ebiten.MaxTPS (v2.4) -> ebiten.TPS"),
(r"\bebiten\.CurrentTPS\(", "ebiten.CurrentTPS (v2.4) -> ebiten.ActualTPS"),
(r"\bebiten\.CurrentFPS\(", "ebiten.CurrentFPS (v2.4) -> ebiten.ActualFPS"),
(r"\bebiten\.UncappedTPS\b", "ebiten.UncappedTPS (v2.2) -> ebiten.SyncWithFPS"),
(r"\bebiten\.DeviceScaleFactor\(", "ebiten.DeviceScaleFactor (v2.6) -> ebiten.Monitor().DeviceScaleFactor()"),
(r"\bebiten\.ScreenSizeInFullscreen\(", "ebiten.ScreenSizeInFullscreen (v2.6) -> ebiten.Monitor().Size()"),
(r"\bebiten\.SetFPSMode\(|\bebiten\.FPSMode\(|\bebiten\.FPSMode[A-Z]", "FPSMode (v2.5) -> ebiten.SetVsyncEnabled"),
(r"\bebiten\.SetWindowResizable\(", "ebiten.SetWindowResizable (v2.3) -> ebiten.SetWindowResizingMode"),
(r"\bebiten\.IsWindowResizable\(", "ebiten.IsWindowResizable (v2.3) -> ebiten.WindowResizingMode"),
(r"\bebiten\.SetScreenTransparent\(|\bebiten\.SetInitFocused\(", "SetScreenTransparent/SetInitFocused (v2.5) -> RunGameWithOptions"),
(r"\bebiten\.SetScreenFilterEnabled\(", "ebiten.SetScreenFilterEnabled (v2.5) -> FinalScreenDrawer"),
(r"\bebiten\.ScheduleFrame\(", "ebiten.ScheduleFrame (v2.5) -> SetScreenClearedEveryFrame(false)"),
(r"\bebiten\.InputChars\(", "ebiten.InputChars (v2.2) -> ebiten.AppendInputChars"),
(r"\bebiten\.GamepadIDs\(", "ebiten.GamepadIDs (v2.2) -> ebiten.AppendGamepadIDs"),
(r"\bebiten\.TouchIDs\(", "ebiten.TouchIDs (v2.2) -> ebiten.AppendTouchIDs"),
(r"\bebiten\.GamepadAxis\(", "ebiten.GamepadAxis (v2.2) -> ebiten.GamepadAxisValue"),
(r"\bebiten\.GamepadAxisNum\(|\bebiten\.GamepadButtonNum\(", "Gamepad*Num (v2.4) -> Gamepad*Count"),
(r"\binpututil\.(PressedKeys|JustPressedTouchIDs|JustConnectedGamepadIDs)\(", "inpututil.* slice getters (v2.2) -> the Append* variants"),
(r"\bebiten\.ColorM\b", "ebiten.ColorM (v2.5) -> ColorScale, or the colorm package"),
(r"\bebiten\.CompositeMode\w*\b", "CompositeMode (v2.5) -> Blend"),
(r"\baudio\.NewPlayer(FromBytes)?\(", "audio.NewPlayer* functions (v2.2) -> (*audio.Context).NewPlayer*"),
(r"\b(wav|mp3|vorbis)\.Decode\(", "Decode(ctx, r) (v2.1) -> DecodeF32 or DecodeWithSampleRate"),
(r"\baudio\.Resample(F32)?\(", "audio.Resample* (v2.9) -> audio.ResampleReader* (or decode at the context rate)"),
(r"\btext\.Advance\(", "text.Advance (v2.10) -> text.AdvanceAt"),
]
# Method names that other types may share: reported as warnings.
DEPRECATED_MAYBE = [
(r"\.AppendVerticesAndIndicesFor(Filling|Stroke)\(", "Path.AppendVerticesAndIndicesFor* (v2.9) -> vector.FillPath / StrokePath (if this is a vector.Path)"),
(r"\.ColorM\.", "DrawImageOptions.ColorM (v2.5) -> ColorScale, or colorm (if this is an Ebitengine options value)"),
(r"\.ReplacePixels\(", "Image.ReplacePixels (v2.4) -> Image.WritePixels (if this is an ebiten.Image)"),
(r"\.Dispose\(\)", "Image/Shader.Dispose (v2.7) -> Deallocate, or just drop the reference (ignore if not an ebiten type)"),
]
# Like DEPRECATED_MAYBE, but only checked in files that import the Ebitengine audio package.
DEPRECATED_PLAYER = [
(r"\.Close\(\)", "Player.Close (v2.10) -> Player.PauseAndStopReading (if this is an audio.Player)"),
(r"\.Seek\([^,()]*\)", "Player.Seek (v2.6) -> Player.SetPosition (if this is an audio.Player)"),
(r"\.Current\(\)", "Player.Current (v2.6) -> Player.Position (if this is an audio.Player)"),
]
# Checks inside Draw bodies. Capture bookkeeping is an intentional exception.
IN_DRAW = [
(r"\bebiten\.NewImage(FromImage)?\(", "creates a GPU image every frame; create once and reuse"),
(r"\btext\.NewGoTextFaceSource\(", "parses a font every frame; load fonts once"),
(r"\binpututil\.", "inpututil must be called in Update, not Draw"),
(r"\baudio\.NewContext\(", "audio context created in Draw"),
(r"\.Play\(\)", "plays sound from Draw; Draw can run 0..n times per tick, trigger sounds in Update"),
]
# Checks inside Update and Draw bodies.
IN_LOOP = [
(r"\btime\.(Now|Since)\(", "wall-clock time in the game loop; use ticks for simulation, wall time may be intentional for profiling or external deadlines"),
(r"\.ReadPixels\(", "possible GPU readback (if this is an ebiten.Image); review repeated reads, screenshots/tests are intentional"),
]
def strip_go(src, keep_strings=False):
"""Mask comments and literals, preserving offsets and line structure."""
out, i, n = [], 0, len(src)
def mask(s):
return "".join("\n" if c == "\n" else " " for c in s)
while i < n:
c = src[i]
if src.startswith("//", i):
j = src.find("\n", i)
j = n if j < 0 else j
out.append(mask(src[i:j]))
i = j
elif src.startswith("/*", i):
j = src.find("*/", i + 2)
j = n if j < 0 else j + 2
out.append(mask(src[i:j]))
i = j
elif c == "`":
j = src.find("`", i + 1)
j = n if j < 0 else j + 1
out.append(src[i:j] if keep_strings else mask(src[i:j]))
i = j
elif c in "\"'":
j = i + 1
while j < n and src[j] != c and src[j] != "\n":
j += 2 if src[j] == "\\" else 1
out.append(src[i:j+1] if keep_strings and c == '"' else mask(src[i:j+1]))
i = j + 1
else:
out.append(c)
i += 1
return "".join(out)
def imports(src, code):
"""Return named imports (local name, path, line), excluding comments/literals."""
uncommented = strip_go(src, keep_strings=True)
for m in re.finditer(r"\bimport\b", code):
start = m.end()
while start < len(code) and uncommented[start].isspace():
start += 1
end = code.find(")", start) if start < len(code) and code[start] == "(" else code.find("\n", start)
if end < 0:
end = len(code)
for spec in re.finditer(r'(?:(\w+|\.)\s+)?["`]([^"`\n]+)["`]', uncommented[start:end]):
path = spec.group(2)
alias = spec.group(1) or ("text" if path.endswith("/text/v2") else path.rsplit("/", 1)[-1])
if path == "github.com/hajimehoshi/ebiten/v2":
alias = spec.group(1) or "ebiten"
yield alias, path, src.count("\n", 0, start+spec.start()) + 1
def method_bodies(code, name):
"""Yield (start_line, body) for methods `func (recv) Name(`."""
for m in re.finditer(r"func\s*\([^)]*\)\s*" + name + r"\s*\(", code):
brace = code.find("{", m.end())
if brace < 0:
continue
depth, j = 0, brace
while j < len(code):
if code[j] == "{":
depth += 1
elif code[j] == "}":
depth -= 1
if depth == 0:
break
j += 1
yield code.count("\n", 0, brace) + 1, code[brace:j]
def scan_file(path, raw_imports):
with open(path, encoding="utf-8", errors="replace") as f:
src = f.read()
code = strip_go(src)
imported = list(imports(src, code))
# Canonicalise only packages actually imported. Unrelated packages named
# audio/text/vector must not be mistaken for Ebitengine APIs.
names = {}
base = "github.com/hajimehoshi/ebiten/v2"
known = {base: "ebiten", "time": "time"}
known.update({base+"/"+p: p.rsplit("/", 1)[-1] for p in
("ebitenutil", "inpututil", "vector", "audio", "audio/wav", "audio/mp3", "audio/vorbis")})
known.update({base+"/text": "text", base+"/text/v2": "text"})
for alias, import_path, _ in imported:
if alias not in (".", "_") and import_path in known:
names[alias] = known[import_path]
canonical = set(known.values())
# A name after "." is a field or method (s.audio.NewPlayer), not a package.
code = re.sub(r"(?<=\.)(" + "|".join(sorted(canonical)) + r")\b", r"__field_\1", code)
code = re.sub(r"(?<![.\w])([A-Za-z_]\w*)\s*(?=\.)", lambda m: names.get(
m.group(1), "__other_"+m.group(1) if m.group(1) in canonical else m.group(1)), code)
found = []
def add(sev, line, msg):
found.append((sev, path, line, msg))
for pat, msg in DEPRECATED:
for m in re.finditer(pat, code):
add("warn", code.count("\n", 0, m.start()) + 1, "deprecated: " + msg)
for pat, msg in DEPRECATED_MAYBE + (DEPRECATED_PLAYER if "audio" in names.values() else []):
for m in re.finditer(pat, code):
add("warn", code.count("\n", 0, m.start()) + 1, "possibly deprecated: " + msg)
for _, import_path, line in imported:
if import_path == base+"/text":
add("warn", line, 'old import: prefer "github.com/hajimehoshi/ebiten/v2/text/v2" for new text code')
if import_path == "github.com/hajimehoshi/ebiten":
add("warn", line, "old import: Ebitengine v1 -> github.com/hajimehoshi/ebiten/v2")
for line0, body in method_bodies(code, "Draw"):
for pat, msg in IN_DRAW:
for m in re.finditer(pat, body):
add("warn", line0 + body.count("\n", 0, m.start()), "in Draw: " + msg)
for name in ("Update", "Draw"):
for line0, body in method_bodies(code, name):
# At is also a common grid/map method. Without Go type information,
# neither its name nor argument count proves an Image readback.
for m in re.finditer(r"\.At\s*\(", body):
add("note", line0 + body.count("\n", 0, m.start()), "in " + name +
": At call has an unknown receiver type; only ebiten.Image.At reads GPU pixels, ordinary grid/map lookups are fine")
for pat, msg in IN_LOOP:
for m in re.finditer(pat, body):
add("warn", line0 + body.count("\n", 0, m.start()), "in " + name + ": " + msg)
for m in re.finditer(r"\baudio\.NewContext\(", code):
raw_imports.append((path, code.count("\n", 0, m.start()) + 1))
return found
def main(argv=None):
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", help="project directory or a single .go file")
ap.add_argument("--json", action="store_true", help="print one JSON object instead of text")
ap.add_argument("--strict", action="store_true", help="exit 1 on warnings too")
ap.add_argument("--max-issues", type=int, default=50, metavar="N",
help="show at most N issues (default: 50; 0 = no limit)")
ap.add_argument("--include-tests", action="store_true", help="also scan _test.go files")
args = ap.parse_args(argv)
if args.max_issues < 0:
ap.error("--max-issues must be 0 or more")
target = Path(args.path).expanduser().resolve()
if not target.exists():
print(f"error: {target} does not exist", file=sys.stderr)
return 2
files = []
if target.is_file():
if target.suffix != ".go":
print("error: not a .go file", file=sys.stderr)
return 2
files = [target]
root = target.parent
elif target.is_dir():
root = target
def walk_error(error):
raise error
try:
for directory, dirs, names in os.walk(target, onerror=walk_error):
dirs[:] = [d for d in dirs if d not in ("vendor", "node_modules") and not d.startswith(".")]
for name in names:
if name.endswith(".go") and (args.include_tests or not name.endswith("_test.go")):
files.append(Path(directory) / name)
except OSError as error:
print(f"error: cannot scan target: {error}", file=sys.stderr)
return 2
else:
print(f"error: {target} is not a file or folder", file=sys.stderr)
return 2
if not files:
print("error: no .go files found", file=sys.stderr)
return 2
findings, contexts = [], []
base = "github.com/hajimehoshi/ebiten/v2"
has_engine = False
try:
module = root / "go.mod"
if module.is_file():
has_engine = bool(re.search(r"(?m)^\s*(?:require\s+)?" + re.escape(base) + r"\s+v2\.",
module.read_text(encoding="utf-8")))
for path in sorted(files):
source = path.read_text(encoding="utf-8", errors="replace")
has_engine |= any(p == base or p.startswith(base + "/")
for _, p, _ in imports(source, strip_go(source)))
findings += scan_file(path, contexts)
except (OSError, UnicodeError) as error:
print(f"error: cannot read target: {error}", file=sys.stderr)
return 2
if not has_engine:
print("error: no Ebitengine v2 dependency or Go import found", file=sys.stderr)
return 2
if len(contexts) > 1:
for p, line in contexts:
findings.append(("warn", p, line, "multiple audio.NewContext call sites; only one context may exist per process. "
"Check whether these sites can execute together (build tags and alternative entry points may exclude them)"))
warnings, notes = [], []
for severity, path, line, message in sorted(findings, key=lambda f: (str(f[1]), f[2], f[3])):
if severity == "note":
code = "ambiguous-at"
fix = "Check the receiver type; avoid repeated Image.At reads in the loop. No action is needed for CPU grid/map lookups."
elif "deprecated:" in message or message.startswith("old import:"):
code = "deprecated-api" if "deprecated:" in message else "old-import"
fix = "Confirm the imported type and pinned engine version before applying the suggested replacement."
elif message.startswith("multiple audio.NewContext"):
code = "audio-context-sites"
fix = "Ensure only one context is constructed per process; inspect build tags and alternative entrypoints."
else:
code = "loop-review"
fix = "Review the operation in context; keep gameplay/input/audio triggers in Update and reuse expensive resources."
(notes if severity == "note" else warnings).append(
{"code": code, "file": Path(path).relative_to(root).as_posix(),
"line": line, "message": message, "fix": fix})
shown = warnings[:args.max_issues] if args.max_issues else warnings
shown_notes = notes[:max(0, args.max_issues - len(shown))] if args.max_issues else notes
omitted = len(warnings) + len(notes) - len(shown) - len(shown_notes)
truncated = omitted > 0
if args.json:
print(json.dumps({"tool": TOOL, "schema": SCHEMA, "root": root.as_posix(),
"files_checked": len(files), "error_count": 0,
"warning_count": len(warnings), "errors": [],
"warnings": shown, "note_count": len(notes), "notes": shown_notes,
"truncated": truncated}, indent=2))
else:
for issue in shown:
print(f"WARNING {issue['file']}:{issue['line']}: {issue['message']}")
print(f" fix: {issue['fix']}")
for issue in shown_notes:
print(f"NOTE {issue['file']}:{issue['line']}: {issue['message']}")
print(f" fix: {issue['fix']}")
if truncated:
print(f"... and {omitted} more")
print(f"Summary: 0 errors, {len(warnings)} warnings in {len(files)} files checked")
if notes:
print(f"Notes: {len(notes)} informational findings (do not affect --strict)")
return 1 if args.strict and warnings else 0
if __name__ == "__main__":
sys.exit(main())
scripts/new_game.py#!/usr/bin/env python3
"""Create a new Ebitengine game project from the bundled starter.
Copies ../assets/starter into a new directory, sets the Go module path and window
title, and writes go.mod pinned to an Ebitengine version. It does not run `go`
or touch the network; run `go mod tidy` afterwards to fetch dependencies.
The output directory must not exist, or must be empty.
With --ci it also writes .github/workflows/ci.yml (tests, headless smoke run,
cross-compiles, tagged releases) and copies check_ebitengine.py and
package_macos_app.py into tools/, which that workflow calls.
Examples:
python3 new_game.py --out ./mygame --module example.com/mygame --title "My Game"
python3 new_game.py --out ./mygame --module github.com/you/mygame --ci --bundle-id com.you.mygame
cd mygame && go mod tidy && go run .
"""
import argparse
import html
import json
import shlex
import unicodedata
import os
import re
import shutil
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
STARTER = os.path.join(HERE, "..", "assets", "starter")
WORKFLOW = os.path.join(HERE, "..", "assets", "ci", "github-actions.yml")
MODULE_RE = re.compile(r"^[a-z0-9][a-z0-9._~\-]*(/[A-Za-z0-9._~\-]+)*$")
VERSION_RE = re.compile(r"^v2\.\d+\.\d+(-[0-9A-Za-z.\-]+)?$")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", required=True, help="new project directory (must not exist or be empty)")
ap.add_argument("--module", required=True, help="Go module path, e.g. example.com/mygame or github.com/you/game")
ap.add_argument("--title", default="My Game", help="window and title-screen text")
ap.add_argument("--ebiten", default="v2.10.5", help="Ebitengine version for go.mod (default v2.10.5)")
ap.add_argument("--go", default="1.25.0", help="go directive (Ebitengine v2.10 needs 1.25 or later)")
ap.add_argument("--ci", action="store_true", help="add a GitHub Actions workflow and the tools/ scripts it uses")
ap.add_argument("--bundle-id", help="macOS bundle ID for --ci release builds (default com.example.<name>)")
args = ap.parse_args()
if not MODULE_RE.fullmatch(args.module) or any(
part in (".", "..") or part.startswith(".") or part.endswith(".")
for part in args.module.split("/")
):
print(f"error: invalid module path {args.module!r}", file=sys.stderr)
return 2
if not VERSION_RE.fullmatch(args.ebiten):
print(f"error: --ebiten must look like v2.10.5, got {args.ebiten!r}", file=sys.stderr)
return 2
if int(args.ebiten.split(".")[1]) < 10:
print("error: this starter uses Ebitengine v2.10+ APIs", file=sys.stderr)
return 2
if not re.fullmatch(r"1\.\d+(\.\d+)?", args.go):
print(f"error: --go must look like 1.25.0, got {args.go!r}", file=sys.stderr)
return 2
if int(args.go.split(".")[1]) < 25:
print("error: Ebitengine v2.10 needs Go 1.25 or later", file=sys.stderr)
return 2
if not args.title.strip() or any(unicodedata.category(c).startswith("C") for c in args.title):
print("error: --title must be nonempty and contain no control characters", file=sys.stderr)
return 2
parts = args.module.split("/")
if len(parts) > 1 and re.fullmatch(r"v\d+", parts[-1]):
parts.pop() # major-version suffix: github.com/you/game/v2 -> game
name = re.sub(r"[^a-z0-9_-]", "", parts[-1].lower()) or "game"
bundle_name = name.replace("_", "-").strip("-") or "game"
bundle_id = args.bundle_id or f"com.example.{bundle_name}"
if not re.match(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$", bundle_id):
print(f"error: --bundle-id {bundle_id!r} is not reverse-DNS", file=sys.stderr)
return 2
if not os.path.isdir(STARTER):
print(f"error: starter not found at {STARTER}", file=sys.stderr)
return 1
if args.ci and not os.path.isfile(WORKFLOW):
print(f"error: workflow not found at {WORKFLOW}", file=sys.stderr)
return 1
out = os.path.abspath(args.out)
if os.path.exists(out) and (not os.path.isdir(out) or os.listdir(out)):
print(f"error: {out} exists and is not an empty directory", file=sys.stderr)
return 1
title_literal = json.dumps(args.title, ensure_ascii=False)
shutil.copytree(STARTER, out, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for root, _, names in os.walk(out):
for n in names:
if not n.endswith(".go"):
continue
p = os.path.join(root, n)
with open(p, encoding="utf-8") as f:
s = f.read()
s = s.replace('"starter/world"', f'"{args.module}/world"')
s = s.replace('ebiten.SetWindowTitle("Starter")', f'ebiten.SetWindowTitle({title_literal})')
s = s.replace('"STARTER"', title_literal)
with open(p, "w", encoding="utf-8") as f:
f.write(s)
page = os.path.join(out, "web", "index.html")
if os.path.exists(page):
with open(page, encoding="utf-8") as f:
s = f.read().replace("<title>Starter</title>", f"<title>{html.escape(args.title)}</title>")
with open(page, "w", encoding="utf-8") as f:
f.write(s)
with open(os.path.join(out, "go.mod"), "w", encoding="utf-8") as f:
f.write(f"module {args.module}\n\ngo {args.go}\n\nrequire github.com/hajimehoshi/ebiten/v2 {args.ebiten}\n")
if args.ci:
os.makedirs(os.path.join(out, ".github", "workflows"))
with open(WORKFLOW, encoding="utf-8") as f:
wf = f.read()
wf = wf.replace("GAME: mygame", f"GAME: {name}").replace("com.example.$GAME", bundle_id)
with open(os.path.join(out, ".github", "workflows", "ci.yml"), "w", encoding="utf-8") as f:
f.write(wf)
os.makedirs(os.path.join(out, "tools"))
for tool in ("check_ebitengine.py", "package_macos_app.py"):
shutil.copy2(os.path.join(HERE, tool), os.path.join(out, "tools", tool))
print(f"created {out}")
print("next:")
print(f" cd {shlex.quote(out)}")
print(" go mod tidy # fetches Ebitengine and writes go.sum")
print(" go test ./world/... # game rules, no graphics initialization")
print(" go run . # play")
print(" go run . -demo -shot shot.png # scripted run, saves a frame, exits")
if args.ci:
print(" git init && git add . && git commit # then push; tag v1.0.0 for a release")
return 0
if __name__ == "__main__":
sys.exit(main())
scripts/package_macos_app.py#!/usr/bin/env python3
"""Assemble a macOS .app bundle from Ebitengine (Go) binaries.
Builds Name.app/Contents/{Info.plist, MacOS/<exe>, Resources/<icon>.icns}.
With both --amd64 and --arm64 the binaries are merged into one universal
binary with `lipo` (macOS) or `llvm-lipo` (Linux, from LLVM). With one
binary the app supports only that architecture.
This script does not sign or notarise. On a Mac, after it runs:
codesign --force --options runtime --timestamp --sign "Developer ID Application: ..." Name.app
then notarise (xcrun notarytool or github.com/hajimehoshi/notarize), staple,
and zip with `ditto -c -k --keepParent Name.app name_darwin.zip`.
Build the inputs first, for example:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -o build/game_amd64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -o build/game_arm64 .
Exit codes: 0 success, 1 tool failure, 2 bad arguments or inputs.
"""
from __future__ import annotations
import argparse
import plistlib
import re
import shutil
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
MACHO_CPUS = {"amd64": 0x01000007, "arm64": 0x0100000C}
def fail(msg: str, code: int = 2) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def find_lipo() -> str | None:
for name in ("lipo", "llvm-lipo"):
if shutil.which(name):
return shutil.which(name)
for p in sorted(Path("/usr/bin").glob("llvm-lipo-*"), reverse=True):
return str(p)
return None
def check_macho(path: Path, arch: str) -> tuple[int, int, int] | None:
if not path.is_file():
fail(f"{path}: not a file")
with path.open("rb") as f:
header = f.read(32)
if len(header) != 32 or header[:4] != b"\xcf\xfa\xed\xfe":
fail(f"{path}: not a thin 64-bit Mach-O binary (build with GOOS=darwin GOARCH={arch})")
_, cpu, _, filetype, count, size, _, _ = struct.unpack("<8I", header)
if cpu != MACHO_CPUS[arch] or filetype != 2: # MH_EXECUTE
fail(f"{path}: expected a darwin/{arch} executable")
if size > path.stat().st_size - 32:
fail(f"{path}: truncated Mach-O load commands")
commands = f.read(size)
offset, minimum = 0, None
for _ in range(count):
if offset+8 > len(commands):
fail(f"{path}: truncated Mach-O load command")
cmd, length = struct.unpack_from("<2I", commands, offset)
if length < 8 or offset+length > len(commands):
fail(f"{path}: invalid Mach-O load command size")
if cmd == 0x32 and length >= 24: # LC_BUILD_VERSION
platform, version = struct.unpack_from("<2I", commands, offset+8)
if platform != 1: # PLATFORM_MACOS
fail(f"{path}: build platform is not macOS")
minimum = (version >> 16, (version >> 8) & 255, version & 255)
elif cmd == 0x24 and length >= 16: # LC_VERSION_MIN_MACOSX
version = struct.unpack_from("<I", commands, offset+8)[0]
minimum = (version >> 16, (version >> 8) & 255, version & 255)
offset += length
return minimum
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--name", required=True, help="display name, e.g. 'Coin Run' (Coin Run.app)")
ap.add_argument("--exe", help="executable name inside the bundle (default: name without spaces, lower case)")
ap.add_argument("--bundle-id", required=True, help="reverse-DNS identifier, e.g. com.example.coinrun")
ap.add_argument("--version", default="1.0.0", help="CFBundleShortVersionString (default 1.0.0)")
ap.add_argument("--build", help="CFBundleVersion (default: same as --version)")
ap.add_argument("--amd64", type=Path, help="darwin/amd64 binary")
ap.add_argument("--arm64", type=Path, help="darwin/arm64 binary")
ap.add_argument("--icon", type=Path, help=".icns file (optional)")
ap.add_argument("--min-macos", help="LSMinimumSystemVersion (default: highest deployment target in the input binaries)")
ap.add_argument("--out", type=Path, default=Path("build"), help="output directory (default: build)")
ap.add_argument("--force", action="store_true", help="replace an existing bundle")
args = ap.parse_args()
if not args.amd64 and not args.arm64:
fail("give --amd64, --arm64 or both")
if (not args.name.strip() or args.name in (".", "..")
or any(c in args.name for c in "/\\")
or any(ord(c) < 32 or ord(c) == 127 for c in args.name)):
fail("--name must be a nonempty file name without path separators or control characters")
if not re.fullmatch(r"[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+", args.bundle_id):
fail(f"--bundle-id {args.bundle_id!r} is not reverse-DNS (letters, digits, hyphens, dots)")
if not re.fullmatch(r"\d+(\.\d+){0,2}", args.version):
fail(f"--version {args.version!r} must be like 1.2.3")
if args.build and not re.fullmatch(r"\d+(\.\d+){0,2}", args.build):
fail(f"--build {args.build!r} must be numeric, like 42 or 1.2.3")
if args.min_macos and not re.fullmatch(r"\d+(\.\d+){1,2}", args.min_macos):
fail(f"--min-macos {args.min_macos!r} must be like 12.0")
exe = args.exe or re.sub(r"[^A-Za-z0-9_-]", "", args.name).lower()
if not re.fullmatch(r"[A-Za-z0-9_-]+", exe):
fail("--exe must contain only letters, digits, underscores and hyphens")
bins = [p for p in (args.amd64, args.arm64) if p]
minimums = []
for arch in ("amd64", "arm64"):
if b := getattr(args, arch):
if minimum := check_macho(b, arch):
minimums.append(minimum)
if args.min_macos:
chosen = tuple(map(int, args.min_macos.split(".")))
chosen += (0,) * (3-len(chosen))
if minimums and chosen < max(minimums):
fail("--min-macos is below the deployment target required by an input binary")
elif len(minimums) == len(bins):
args.min_macos = ".".join(map(str, max(minimums)))
else:
fail("cannot determine minimum macOS from an input; pass --min-macos explicitly")
if args.icon and (not args.icon.is_file() or args.icon.suffix != ".icns"):
fail(f"--icon {args.icon} must be an existing .icns file")
app = args.out / f"{args.name}.app"
if app.is_symlink():
fail(f"{app} is a symlink; choose another output path")
if app.exists():
if not args.force:
fail(f"{app} exists; pass --force to replace it")
if not app.is_dir():
fail(f"{app} exists and is not a directory")
lipo = find_lipo() if len(bins) == 2 else None
if len(bins) == 2 and not lipo:
fail("lipo or llvm-lipo not found (install Xcode command line tools or LLVM)", 1)
args.out.mkdir(parents=True, exist_ok=True)
# Build away from the final name: a failed lipo must preserve a previous app.
with tempfile.TemporaryDirectory(prefix=".macos-app-", dir=args.out) as tmp:
staged = Path(tmp) / app.name
assemble(args, bins, exe, staged, lipo)
if app.exists():
shutil.rmtree(app)
staged.rename(app)
archs = "universal (amd64+arm64)" if len(bins) == 2 else ("amd64" if args.amd64 else "arm64")
print(f"wrote {app} [{archs}]")
print("next (on a Mac): codesign --options runtime, notarise, staple, then ditto -c -k --keepParent")
return 0
def assemble(args, bins, exe: str, app: Path, lipo: str | None) -> None:
macos = app / "Contents" / "MacOS"
res = app / "Contents" / "Resources"
macos.mkdir(parents=True)
res.mkdir(parents=True)
target = macos / exe
if len(bins) == 2:
r = subprocess.run([lipo, "-create", str(bins[0]), str(bins[1]), "-output", str(target)],
capture_output=True, text=True)
if r.returncode != 0:
fail(f"lipo failed: {r.stderr.strip()}", 1)
else:
shutil.copyfile(bins[0], target)
target.chmod(0o755)
plist = {
"CFBundlePackageType": "APPL",
"CFBundleName": args.name,
"CFBundleDisplayName": args.name,
"CFBundleExecutable": exe,
"CFBundleIdentifier": args.bundle_id,
"CFBundleShortVersionString": args.version,
"CFBundleVersion": args.build or args.version,
"CFBundleInfoDictionaryVersion": "6.0",
"LSMinimumSystemVersion": args.min_macos,
"NSHighResolutionCapable": True,
"LSApplicationCategoryType": "public.app-category.games",
}
if args.icon:
shutil.copyfile(args.icon, res / args.icon.name)
plist["CFBundleIconFile"] = args.icon.name
with (app / "Contents" / "Info.plist").open("wb") as f:
plistlib.dump(plist, f)
if __name__ == "__main__":
sys.exit(main())
SKILL.md---
name: ebitengine-game
description: >
Create, improve, debug and package playable 2D games in Go with Ebitengine
(github.com/hajimehoshi/ebiten/v2). Use for Ebitengine game requests: game design
translated into a complete loop, input, rendering, text, audio, scenes, gameplay
tests and desktop, browser or mobile builds. Also use when fixing these systems
in an existing Ebitengine game. Do not apply to unrelated Go software or
engine-internal patches merely because they depend on Ebitengine.
---
# Ebitengine game
Deliver a game that is understandable, responsive and complete at the requested
scope. One request can include several internal build/play/refine passes. A
scaffold, successful build or attractive screenshot alone is not a finished game.
For a focused fix, preserve the project's design and limit the workflow to the
affected behavior.
The bundled starter targets **Ebitengine v2.10.5 / Go 1.25+**. This is a pinned
baseline, not a claim about the latest release. Keep an existing project's
versions unless an upgrade is needed and authorized. Check the pinned version's
public API before using newer or experimental APIs. References contain patterns
to adapt; only the bundled scripts and starter are executable deliverables.
Their checks do not validate every reference snippet.
## Delivery workflow
1. **Inspect and decide.** Read repository instructions, `go.mod`, the `RunGame`
entrypoint and existing tests/assets. For new games, read
[Design and quality](references/design-and-quality.md). Establish the player's
goal, repeated decision, consequence, end/retry path, intended look, platform
and supported input. Infer reasonable defaults; ask only for missing choices
that materially change the product. Record a short brief and observable
acceptance criteria in the project.
2. **Build a representative playable slice.** Make the central interaction and
its feedback work with the intended controls and visual direction. Check the
feel before expanding content. Keep scope small enough to finish; choose
authored or procedural content based on the game, not the template. Calibrate
content against actual player capabilities before multiplying levels. For an
art set, establish shared perspective, scale, anchors and palette roles using
[Assets and animation](references/assets-and-animation.md).
3. **Complete and refine.** Implement the promised start → play → outcome →
retry/continue loop (or the appropriate persistent/sandbox loop). Refine
hierarchy, animation, sound, difficulty and transitions in context. Read only
the technical references needed for the selected systems. Fix discovered
defects within this delivery rather than asking the user to iterate for you.
4. **Verify the product.** Test rules and important transitions, exercise real
input, inspect representative rendered states, and build/run requested
targets. Include failure/retry and boundary cases. A scripted demo cannot
establish control feel; a silent audio sink cannot establish sound quality.
For automated multi-step interactions, pair observed images with actual state
at the same tick; see [Playtest diagnostics](references/playtest-diagnostics.md).
5. **Hand off.** Supply launch/build instructions, actual artifact paths and
asset licences. State what was run and observed, what remains unverified,
and any material limitations. Publishing, purchases and external uploads are
separate actions within the user's requested scope.
## Starting a project
Resolve commands relative to this skill's directory, not the game's working
directory. Set `skill_dir` to the actual installed location, then for example:
```sh
python3 "$skill_dir/scripts/new_game.py" --out mygame \
--module example.com/mygame --title "My Game"
cd mygame
go mod tidy
go test ./world/...
```
The starter demonstrates scenes, action input, text, sound, a camera, pause and
scripted capture using a small **platformer**. It is an integration example,
not a genre or architecture requirement. Replace its rules, controls, level,
presentation and scene flow to fit the brief; a puzzle or card game should not
inherit gravity, jumping, camera scrolling or a platformer HUD. Existing projects
rarely need a scaffold. `--ci` adds a workflow and tools; inspect its triggers,
targets and release behavior before enabling it.
## Loop and resource contracts
| Method | Responsibility | Important distinction |
| --- | --- | --- |
| `Update() error` | Sample actions, advance simulation, apply scene changes and trigger event audio | Default target is 60 fixed ticks/s. Catch-up updates, focus pauses and slow machines affect actual scheduling. |
| `Draw(screen *ebiten.Image)` | Render the state produced by updates | Calls are not one-to-one with ticks. Do not advance gameplay, consume input edges or start sounds here. Renderer-owned caches are separate from simulation state. |
| `Layout(outsideW, outsideH int) (int, int)` | Choose logical dimensions for rendering and input | Fixed and responsive designs are both valid. Account for small windows, aspect ratio and readability. |
- Express simulation durations in ticks or use an explicit fixed `dt`. At 60
TPS, 30 ticks is 0.5 seconds of simulation time. `ActualTPS` is a diagnostic,
not a delta. `SetTPS` changes the whole update clock; it is not a universal
slow-motion control. Avoid `SyncWithFPS` for rules that require a fixed step.
- Return `ebiten.Termination` from `Update` for a clean stop. Other errors return
from `RunGame`; propagate failures rather than hiding them.
- Load/decode images and font sources once; reuse offscreens and hot-path
slices. Profile before making pooling or zero-allocation rules universal.
- Separate rules from device/GPU code where that makes them testable. A small
game need not grow an ECS, scene stack or service layer without need.
## Essential Ebitengine choices
- **Input:** Map devices to actions in `Update`. Separate held/pressed/released;
combine bindings before computing an action edge if that is the intended
behavior. Standard gamepad mappings need an availability check and stick dead
zones. Verify the advertised devices through menus and retry too.
[Input](references/input.md).
- **Rendering:** `GeoM` operations apply in call order. Pixel art needs deliberate
sampling/scaling; smooth art needs suitable filtering and antialiased geometry.
Preserve layer order while batching; sharing a sheet does not guarantee one
command. Custom final-screen transforms need matching pointer conversion.
[Rendering](references/rendering.md).
- **Text/UI:** Preserve suitable existing bitmap/atlas text rendering. For new
outline-font code, use `text/v2` and parse font sources once. Include fonts
covering the shipped scripts and their licences. Measure actual strings and
verify focus, long text and target-size readability. [Text/UI](references/text-and-ui.md).
- **Audio:** One `audio.Context` per process. Match sample rate and PCM format:
`DecodeF32` does not resample; `DecodeWithSampleRate` returns 16-bit PCM.
Overlapping effects need independent players or a voice pool; bound concurrency
for dense events. Resume browser audio after a gesture. [Audio](references/audio.md).
- **Systems:** Tweens, particles, shake, physics and map loaders are optional
tools supporting the central interaction. [Gameplay systems](references/gameplay-systems.md).
- **State:** Queue external callbacks for `Update`, make reset/retry clear,
separate gameplay and cosmetic randomness, test saves if persistence is
promised. [Architecture/testing](references/architecture-and-testing.md).
## Verification
Run commands from the game directory; retain `go.mod` and `go.sum`. Use repository
build flags; `-buildvcs=false` below avoids depending on surrounding Git metadata.
```sh
go vet ./...
go test ./world/... # starter's GPU-independent rules
python3 "$skill_dir/scripts/check_ebitengine.py" .
go build -buildvcs=false -o game .
```
Run the checker after changing loop/input/audio code or reviewing old APIs. It is
a read-only, text-based advisory scan: inspect warnings in context. `--json`
returns the shared checker schema; `--strict` fails on warnings and `--max-issues N`
limits the report without changing totals. Deprecated APIs are warnings, not
proof of a broken build. A clean result proves neither API correctness nor
quality. Run other relevant package tests too; on Linux, tests that initialize
graphics need `xvfb-run -a`.
The checker cannot infer Go receiver types. Ambiguous `.At()` calls in `Update`/`Draw`
are informational notes, not warnings: ordinary grid/map lookups are fine; verify
whether the receiver is actually an `ebiten.Image` before treating it as a GPU
readback. `ReadPixels()` remains a warning. Notes never fail `--strict`; JSON adds
`note_count` and `notes` to the shared schema. `--max-issues` caps warnings and notes
together, showing warnings first while retaining both totals.
For the starter, capture with `-demo -shot frame.png -shot-tick 240`. On Linux,
use a virtual display and, if needed, null audio without changing HOME:
```sh
audio_config=$(mktemp)
printf 'pcm.!default { type null }\n' > "$audio_config"
ALSA_CONFIG_PATH="$audio_config" xvfb-run -a ./game \
-demo -shot frame.png -shot-tick 240
rm "$audio_config"
```
Open the PNG. Inspect more states as needed: first play, a busy/difficult moment,
failure and outcome. Adapt scripted actions/capture points after changing the
game; the template demo no longer proves anything about a different loop.
For deeper input/replay tests, v2.10's opt-in VM may help (see
[Architecture/testing](references/architecture-and-testing.md)).
Build only promised targets, for example:
```sh
GOOS=windows GOARCH=amd64 go build -buildvcs=false -o game.exe .
GOOS=js GOARCH=wasm go build -buildvcs=false -o game.wasm .
```
Cross-compilation establishes buildability, not runtime support. Run browser
builds over HTTP and inspect console/input/audio when they are a target. Native
SDKs, store bindings and cgo dependencies can add requirements beyond
Ebitengine's pure-Go desktop backend.
## Compose within this catalog
This skill owns Ebitengine/Go APIs. For portable design concerns, pair it with
the relevant installed discipline (for example `game-ui-ux`, `game-feel`,
`create-game-assets`, `input-systems`, `performance-optimization` or
`save-systems`), genre or shipping workflow. Bind the
engine slots to this skill's references, not another engine's example code.
Load only what the task needs; the complete folder also works on its own.
## Read as needed
- [Design and quality](references/design-and-quality.md): brief, genre choices,
complete loop, presentation and playtest acceptance; for new games/redesigns.
- [Project and builds](references/project-and-builds.md): desktop, WASM and mobile.
- [Rendering](references/rendering.md), [input](references/input.md),
[text/UI](references/text-and-ui.md), [audio](references/audio.md).
- [Assets and animation](references/assets-and-animation.md): when making or
importing an art set; shared visual rules, scale, pivots and sprite-sheet QA.
- [Playtest diagnostics](references/playtest-diagnostics.md): when scripting
interactions or reproducing failures; controlled ticks, state/image evidence.
- [Architecture/testing](references/architecture-and-testing.md) and
[gameplay systems](references/gameplay-systems.md): state, saves, animation,
collision and optional rigid bodies. [Tiled maps](references/tilemaps.md) covers
a constrained loader only when the game uses Tiled.
- [Networking](references/networking.md): only for requested online play.
- [Publishing/platforms](references/publishing-and-platforms.md),
[monetization](references/monetization.md), [CI/release](references/ci-and-release.md):
only for requested distribution/integration work.
If available, Ebitengine's repository skills `writing-kage-shaders` and
`run-ebitengine-app-headless` provide version-specific advanced workflows. Other
game-design/store skills are optional supplements; this skill remains usable
without an external skill catalog.