ebitenmcp

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 25, 2026 License: MIT Imports: 45 Imported by: 0

README

go-ebiten-mcp

See, drive and inspect a running Ebitengine game over MCP — and use the same machinery to write visual regression tests.

An agent can take a screenshot of your game, press a key, read a field the game never exported, and tell you what the screen looked like a second before it crashed. So can a test.

Use it

One line:

import ebitenmcp "github.com/bstkhq/go-ebiten-mcp"

func main() {
    ebiten.SetWindowSize(640, 480)

    if err := ebitenmcp.RunGame(&Game{}); err != nil {   // was: ebiten.RunGame
        log.Fatal(err)
    }
}

With EBITEN_MCP_ADDR unset that is ebiten.RunGame — no port, no goroutine — so the line can stay in a release build. Setting it is what starts the server:

EBITEN_MCP_ADDR=127.0.0.1:8384 ./mygame
{ "mcpServers": { "game": { "type": "http", "url": "http://127.0.0.1:8384/mcp" } } }

That is the whole setup. ebitenmcp find lists the games running locally.

If your client can only launch a command and speak over a pipe, use the control server, which starts the game and forwards its tools:

{ "mcpServers": { "game-control": {
    "command": "go",
    "args": ["run", "github.com/bstkhq/go-ebiten-mcp/cmd/ebitenmcp@latest",
             "mcp", "--start", "go run ./cmd/mygame"] } } }

ebitenmcp init writes that file for you and leaves a note in CLAUDE.md.

Do not expose this

There is no authentication. Anyone who can open the port can read the game's memory — unexported fields included — watch its screen and type into it.

  • Never bind a public address.
  • Do not leave it enabled in production. Turn it on to debug, turn it off after.
  • Reach a remote game by forwarding the port: ssh -L 8384:127.0.0.1:8384 kiosk

The tools

Look game_screenshot game_record game_compare game_frames
Drive game_key game_type game_mouse game_touch game_gamepad game_script
Control game_pause game_resume game_step game_wait game_set_tps game_reset
Ask game_inspect game_state game_input_state game_traces game_frametimes game_goroutines game_profile

game_inspect reads unexported fields, because a Go game keeps almost everything unexported. game_frames is a rolling buffer, so the frames before a crash are still there when you think to ask. game_wait blocks until a state path reaches a value, which is what makes a sequence deterministic. Every input tool takes then_wait_ticks and then_screenshot, so press-wait-look is one call.

Panic recovery is opt-in. Set EBITEN_MCP_RECOVER_PANICS=1 or pass WithPanicRecovery(true) to catch a panic in the game and record its stack, tick and last frame drawn; the game stops and the server keeps answering. Without either, panics propagate normally. EBITEN_MCP_RECOVER_PANICS does not turn the server on; that still requires EBITEN_MCP_ADDR.

Tests

The same machinery without the protocol:

func TestMain(m *testing.M) {
    ebitenmcp.RunTests(m, func() ebiten.Game { return NewGame() })
}

func TestMenuMovesOneRowPerPress(t *testing.T) {
    d := ebitenmcp.T(t)

    d.Tap(ebiten.KeyArrowDown)
    if got := d.Inspect("screens.menu.selected"); got != int64(1) {
        t.Fatalf("selection is %v, want 1", got)
    }

    d.Golden("menu_second_row.png")   // -update records it
}

RunTests owns the process's single game loop and gives each test a freshly built game. Golden comparison has a tolerance and writes the expected image, the actual one and their difference on failure.

When there is no display

Ebitengine has no headless backend, so it takes a process in front:

go install github.com/bstkhq/go-ebiten-mcp/cmd/ebitenmcp@latest

ebitenmcp run go test ./...      # your ebiten tests, headless
ebitenmcp run ./mygame           # the game, with the server on
ebitenmcp run --gpu ./mygame     # rendering on the GPU

With DISPLAY already set it changes nothing. Without one it starts a display, in a container if the machine has no X — see the guide.

On macOS and Windows there is nothing to arrange: a game opens its own window, so run only switches the server on and starts it.

Configuration

EBITEN_MCP_ADDR where to listen. Unset means do not serve
EBITEN_MCP_CAPTURE offscreen to skip the screen-sized copy a final pass needs
EBITEN_MCP_RECOVER_PANICS 1 to catch game panics and keep MCP answering; off by default
WithName what a client sees when several games are running
WithFactory how to build a fresh game, for game_reset and the test driver
WithCaptureStage the same as EBITEN_MCP_CAPTURE, in code
WithState publish a named snapshot, reachable as @name
WithAddr the address, ignoring the environment
WithPanicRecovery override EBITEN_MCP_RECOVER_PANICS in code
WithMediaDir where screenshots and video are written; the default is relative to the working directory

Compatibility

Ebitengine v2.9.9, pinned.

Linux macOS Windows Android / iOS
see, drive, pause, inspect, profile yes yes yes yes
game_traces yes yes no builds
game_gamepad yes no no no
ebitenmcp x yes no no n/a

CI runs the whole suite on Linux; on macOS and Windows it builds and runs what needs no display, and for Android and iOS it builds. A tool that cannot do its job on a platform says so — game_traces names the platform, game_gamepad names the device it needs — rather than answering as though there were nothing to report.

On mobile the entry point is different, and so are two of the defaults:

wrapped, _ := ebitenmcp.Wrap(&Game{},
    ebitenmcp.WithAddr("127.0.0.1:8384"),          // no environment on a phone
    ebitenmcp.WithMediaDir(filesDir+"/ebitenmcp"), // the working directory is not writable
)
mobile.SetGame(wrapped)

filesDir is whatever the platform hands the app — Context.getFilesDir() on Android, the app's Documents on iOS. Android needs the INTERNET permission in its manifest for the listener.

GOOS=js compiles, and cannot serve: WebAssembly has no listening sockets. With no address configured that costs nothing, so the line is safe to leave in a browser build.

Your MCP client needs tool support, and ideally image support — about half of what comes back is a picture.

More

  • docs/guide.md — how the input injection works, running headless and in containers, golden images across renderers, gamepad identities, and what the capture stages cost.
  • SKILL.md — how a debugging session goes: where to start, what to switch on before reproducing a problem, and which tools still answer once the game has stopped. The server sends the short version of it to every client that connects.
  • examples/playground — seven screens, each stressing one part of the system. make run starts it.

Licence

MIT.

Documentation

Overview

Package ebitenmcp turns a running Ebitengine game into something an agent can see and drive: frames, state, traces and synthetic input, over MCP.

The whole integration is one line, replacing ebiten.RunGame with this package's. Nothing happens until EBITEN_MCP_ADDR is set, so the call can stay in a shipped build without opening a port or starting a goroutine. Setting it is what opens one, and there is no authentication behind it — see the README.

Index

Constants

View Source
const AddrEnv = wire.AddrEnv

AddrEnv is EBITEN_MCP_ADDR, the variable that turns the server on. Empty means no socket is opened and no goroutine is started, which is why leaving RunGame in a release build costs nothing. Setting it opens an unauthenticated port; bind loopback, and see the README before doing anything else with it.

View Source
const CaptureEnv = wire.CaptureEnv

CaptureEnv is EBITEN_MCP_CAPTURE, which overrides the stage captures come from. Set it to "offscreen" in a game whose final pass is expensive and whose final pass you do not care about; see WithCaptureStage for why that is not the default.

View Source
const MediaDir = ".ebitenmcp/media"

MediaDir is where artifacts are written, relative to the game's working directory. Add it to .gitignore.

View Source
const PanicRecoveryEnv = wire.PanicRecoveryEnv

PanicRecoveryEnv is EBITEN_MCP_RECOVER_PANICS. Set it to "1" to catch game panics and keep the MCP server answering. Unset or any other value leaves Ebitengine's normal panic behaviour unchanged.

View Source
const Path = wire.Path

Path is /mcp, the route the MCP endpoint is mounted on.

View Source
const RendererEnv = wire.RendererEnv

RendererEnv is EBITENMCP_RENDERER, which carries the rasteriser a run is drawing with. `ebitenmcp run` sets it after asking the display; nothing sets it when a game is started by hand, and then the checks below simply have nothing to say.

Variables

View Source
var ErrLoopStalled = errors.New("ebitenmcp: the game loop did not run the request in time")

ErrLoopStalled is returned by anything that needs the game loop when the loop is not running it. A crashed game, a paused one that nobody resumed, or an Update stuck in a deadlock all look like this.

It is a distinct error because "the game is wedged" is a diagnosis, not a failure of the tool that reported it.

Functions

func GamepadsAvailable

func GamepadsAvailable() error

GamepadsAvailable reports why virtual controllers cannot be created, or nil when they can.

func RunGame

func RunGame(game ebiten.Game, opts ...Option) error

RunGame is a drop-in replacement for ebiten.RunGame.

The game runs exactly as it would have. With EBITEN_MCP_ADDR set it also serves MCP, so an agent can watch it, drive it and ask what it is doing.

func RunGameWithOptions

func RunGameWithOptions(game ebiten.Game, ebitenOptions *ebiten.RunGameOptions, opts ...Option) error

RunGameWithOptions is a drop-in replacement for ebiten.RunGameWithOptions.

func RunTests

func RunTests(m *testing.M, factory func() ebiten.Game, opts ...Option)

RunTests runs a package's tests against one game loop.

Put it in TestMain and take the driver in each test:

func TestMain(m *testing.M) {
    ebitenmcp.RunTests(m, func() ebiten.Game { return NewGame() })
}

func TestMenu(t *testing.T) {
    d := ebitenmcp.T(t)
    d.Tap(ebiten.KeyArrowDown)
    d.Golden("menu_selected.png")
}

It does not return: like os.Exit in a plain TestMain, it ends the process with the suite's status.

func UpdateGolden

func UpdateGolden() bool

UpdateGolden reports whether -update was given, in which case golden files are rewritten instead of compared.

Types

type Artifact

type Artifact struct {
	Path   string `json:"path"`
	URL    string `json:"url,omitempty"`
	Kind   string `json:"kind"`
	Width  int    `json:"width"`
	Height int    `json:"height"`
	Bytes  int    `json:"bytes"`

	// Frames and FPS describe a recording, and are left out of everything else.
	//
	// They are here because the answer would otherwise not say, and the two
	// things that write a gif do not agree: with ffmpeg the frames are
	// resampled and roughly half of them are dropped, without it every one is
	// kept and played twice as fast. Same tool, same arguments, different
	// machine. A caller comparing two recordings, or recording frames in order
	// to step through them, needs to know which of those it got.
	Frames int     `json:"frames,omitempty"`
	FPS    float64 `json:"fps,omitempty"`
}

Artifact is one produced file, in the three forms it is needed in: on disk for a person to open, over HTTP for a client that can render it, and inline in the tool result for the agent and for the chat.

type Artifacts

type Artifacts struct {
	Artifact *Artifact `json:"artifact,omitempty"`
	Video    *Artifact `json:"video,omitempty"`
	Sheet    *Artifact `json:"contact_sheet,omitempty"`
}

Artifacts is what a tool produced on disk.

type CommandPanic

type CommandPanic struct {
	Value string
	Stack string
}

CommandPanic is what a caller gets when the work it asked to run inside the game loop panicked.

It exists so that the panic is the caller's problem rather than everybody's. Without it the panic unwinds through the loop and kills the process — which game_inspect can do just by reading, since reflection over a value taken from an unexported field panics on Interface(). A tool marked read-only bringing down the game it is inspecting is the worst outcome available here; being told what went wrong, by a game that is still running, is much the best.

func (*CommandPanic) Error

func (e *CommandPanic) Error() string

type CompareOutput

type CompareOutput struct {
	BeforeTick    int64     `json:"before_tick"`
	AfterTick     int64     `json:"after_tick"`
	ChangedPixels int       `json:"changed_pixels"`
	TotalPixels   int       `json:"total_pixels"`
	Artifact      *Artifact `json:"artifact"`
}

CompareOutput is game_compare's answer.

type Crash

type Crash struct {
	Value string    `json:"value"`
	Stack string    `json:"stack"`
	Tick  int64     `json:"tick"`
	When  time.Time `json:"when"`
	Phase string    `json:"phase"` // "update", "draw" or "final"
}

Crash records a panic the wrapped game raised when panic recovery is enabled.

type Driver

type Driver struct {

	// Timeout bounds each operation. It is generous by default because a
	// software renderer in CI is slow, not because anything should take long.
	Timeout time.Duration

	// GoldenTolerance is the per-channel difference two pixels may have and
	// still count as equal.
	GoldenTolerance int

	// GoldenMaxDiff is the fraction of pixels allowed to differ beyond the
	// tolerance before a golden comparison fails.
	GoldenMaxDiff float64
	// contains filtered or unexported fields
}

Driver drives the game from a test.

Every method fails the test rather than returning an error. A test that has lost the game loop has nothing useful left to do, and threading errors through every call would bury the test's own intent.

func T

func T(t *testing.T) *Driver

T returns a driver for one test, with a game freshly built by the factory.

Resetting rather than restarting is what makes tests independent inside the one loop the process is allowed.

func (*Driver) Click

func (d *Driver) Click(x, y float64, button ...ebiten.MouseButton)

Click presses and releases at a position.

func (*Driver) Drag

func (d *Driver) Drag(x0, y0, x1, y1 float64, steps int)

Drag walks from one point to another with the button held, over several ticks. A game that samples the cursor per tick sees a path, not a jump.

func (*Driver) Golden

func (d *Driver) Golden(name string)

Golden compares the next frame against testdata/<name>.

With -update it writes the file instead, which is how a golden is created and how an intended change is accepted.

The game is held still for the capture. Without that, the frame caught is whichever one the loop happened to draw next, and any tick counter, clock or animation on screen lands on a different value each run — which makes the comparison fail sometimes and pass others, and an intermittent golden is worse than none, because it teaches people to ignore it.

func (*Driver) GoldenImage

func (d *Driver) GoldenImage(name string, got *image.RGBA)

GoldenImage compares an image the caller already has.

func (*Driver) Hold

func (d *Driver) Hold(ticks int, keys ...ebiten.Key)

Hold presses keys, runs the given number of ticks, then releases them.

func (*Driver) Inspect

func (d *Driver) Inspect(path string) any

Inspect reads a path out of the live game, including unexported fields.

func (*Driver) KeyDown

func (d *Driver) KeyDown(keys ...ebiten.Key)

KeyDown presses keys and leaves them pressed.

func (*Driver) KeyUp

func (d *Driver) KeyUp(keys ...ebiten.Key)

KeyUp releases keys held by KeyDown.

func (*Driver) Move

func (d *Driver) Move(x, y float64)

Move puts the cursor at a position in the game's own screen pixels.

func (*Driver) Runtime

func (d *Driver) Runtime() *Runtime

Runtime exposes the underlying runtime, for anything the driver does not wrap.

func (*Driver) Screenshot

func (d *Driver) Screenshot() *image.RGBA

Screenshot captures the next frame at the game's own resolution.

func (*Driver) ScreenshotStage

func (d *Driver) ScreenshotStage(stage Stage) *image.RGBA

ScreenshotStage captures a particular stage.

Screenshot reads the offscreen rather than what the player sees, and for a test that is the right way round: the final screen is the size of the window, so an assertion about it — a golden above all — would depend on the monitor the test happened to run on. Ask for StageFinal when the game's own DrawFinalScreen is the thing under test, and expect to size the window yourself.

func (*Driver) Scroll

func (d *Driver) Scroll(x, y float64)

Scroll turns the wheel.

func (*Driver) Tap

func (d *Driver) Tap(keys ...ebiten.Key)

Tap presses keys for one tick and releases them, which is what a quick key press looks like to a game.

func (*Driver) Tick

func (d *Driver) Tick(n int)

Tick lets the game run n ticks.

func (*Driver) Type

func (d *Driver) Type(text string)

Type enters text as characters, the way a text field reads it.

func (*Driver) WithGame

func (d *Driver) WithGame(fn func(ebiten.Game))

WithGame runs fn against the game under test, inside the game loop.

Assertions are often easier to write against the real type than against an inspected tree, and this is how to do that safely. It used to be a Game() that handed the object back, which read as convenient and was a trap: the caller then touched a live game from the test's goroutine while Update was running, which is the one thing this package's whole design exists to prevent — every tool goes through Do for exactly this reason, and the escape hatch quietly did not.

fn must not block: the loop is waiting for it.

type Frame

type Frame struct {
	Tick  int64       `json:"tick"`
	Time  time.Time   `json:"time"`
	Stage Stage       `json:"stage"`
	Image *image.RGBA `json:"-"`
}

Frame is one captured screen, untouched.

type FrameOutput

type FrameOutput struct {
	Tick     int64     `json:"tick"`
	Stage    Stage     `json:"stage"`
	Artifact *Artifact `json:"artifact"`
	Inline   Size      `json:"inline"`
}

FrameOutput is one captured frame: game_screenshot's answer, and the shape the others borrow.

type FrameTiming

type FrameTiming struct {
	Tick   int64 `json:"tick"`
	Update int64 `json:"update_ns"`
	Draw   int64 `json:"draw_ns"`
	Wall   int64 `json:"wall_ns"`
}

FrameTiming is what one tick cost.

type FramesOutput

type FramesOutput struct {
	Ring RingStatus `json:"ring"`
	Note string     `json:"note,omitempty"`

	Frames    int       `json:"frames,omitempty"`
	FirstTick int64     `json:"first_tick,omitempty"`
	LastTick  int64     `json:"last_tick,omitempty"`
	Sheet     *Artifact `json:"contact_sheet,omitempty"`
}

FramesOutput is game_frames' answer: either the buffer's status, or what it was holding.

type FrametimesOutput

type FrametimesOutput struct {
	Ticks int `json:"ticks"`

	Update Percentiles `json:"update,omitempty"`
	Draw   Percentiles `json:"draw,omitempty"`
	Wall   Percentiles `json:"wall,omitempty"`

	ActualTPS float64 `json:"actual_tps,omitempty"`
	ActualFPS float64 `json:"actual_fps,omitempty"`

	// Reading is the sentence that says what the numbers mean, because the
	// interesting part of them is a comparison and not any one figure.
	Reading string `json:"reading,omitempty"`

	Timings []FrameTiming `json:"timings,omitempty"`
}

FrametimesOutput is game_frametimes' answer.

type GamepadIdentity

type GamepadIdentity struct {
	Name    string
	Bus     uint16
	Vendor  uint16
	Product uint16
	Version uint16
}

GamepadIdentity is what a virtual controller claims to be.

The layout is deliberately not part of it. Every virtual pad has an Xbox 360 controller's buttons and axes, because Ebitengine's controller database has a complete standard mapping for that layout — so a game asking for the standard buttons gets them. A pad with a layout nothing has a mapping for would answer no to IsStandardGamepadLayoutAvailable, which is the first thing most games check, and would prove nothing about the game's real handling.

What a game does switch on is the vendor and the product, and that is worth choosing: a controller that could only ever claim to be an Xbox pad would send those games down a different path than their own hardware.

A zero field means the Xbox 360 pad's own, so setting only Vendor is a sensible thing to do.

func DefaultGamepadIdentity

func DefaultGamepadIdentity() GamepadIdentity

DefaultGamepadIdentity is the Xbox 360 pad, spelled out for a caller who wants to see what the zero value means.

func (GamepadIdentity) SDLID

func (i GamepadIdentity) SDLID() string

SDLID is the identifier Ebitengine builds from the four numbers, and the one a game matches on.

type GamepadState

type GamepadState struct {
	ID             int       `json:"id"`
	Name           string    `json:"name"`
	SDLID          string    `json:"sdl_id"`
	StandardLayout bool      `json:"standard_layout"`
	ButtonsPressed []int     `json:"buttons_pressed"`
	Axes           []float64 `json:"axes"`
}

GamepadState is one controller as the game sees it.

The SDL id is here because it is what a game switches on to decide what kind of controller this is, so seeing it is often the whole answer to "why is the game ignoring my gamepad".

type Gamepads

type Gamepads struct {
	// contains filtered or unexported fields
}

Gamepads are virtual controllers the operating system creates and Ebitengine discovers on its own.

This is the one part of the library that does not reach into Ebitengine at all, so it is also the one part that cannot break when Ebitengine changes — and it keeps working in a build with -tags ebitenmcp_nohook, where the keyboard and mouse injection is compiled out.

It covers what Ebitengine sees: buttons, axes, hats, the name and the SDL id, and the standard layout that follows from them. It does not cover a game that also opens its controller as a USB device, because a uinput device is not on the USB bus. See internal/uinput for what that means in practice.

func (*Gamepads) Apply

func (g *Gamepads) Apply(id ebiten.GamepadID, buttons map[uint16]bool, axes map[uint16]int32) error

Apply sends several changes as one movement.

Input drivers batch events and publish them together, so a stick pushed diagonally is two axis events and one sync. Sending them separately would show up as two straight movements, which is not what the player did.

func (*Gamepads) Axis

func (g *Gamepads) Axis(id ebiten.GamepadID, code uint16, value int32) error

Axis moves one, in the range its profile declared.

func (*Gamepads) Button

func (g *Gamepads) Button(id ebiten.GamepadID, code uint16, pressed bool) error

Button presses or releases one, by evdev code.

func (*Gamepads) Close

func (g *Gamepads) Close()

Close unplugs everything created here.

A virtual device outlives the process that made it unless it is destroyed, so skipping this would leave phantom controllers on the machine.

func (*Gamepads) Connect

func (g *Gamepads) Connect(ctx context.Context, identity GamepadIdentity) (ebiten.GamepadID, error)

Connect creates a controller and returns the id the game will know it by.

It waits for the game to notice. The kernel makes the device immediately, but Ebitengine only picks it up while polling inside its own loop, so returning any earlier would hand back an id for a controller nothing can see yet — and the first button press would land nowhere with no error to explain it.

func (*Gamepads) Connected

func (g *Gamepads) Connected() []ebiten.GamepadID

Connected lists the controllers created here.

func (*Gamepads) Disconnect

func (g *Gamepads) Disconnect(id ebiten.GamepadID) error

Disconnect unplugs a controller this package created.

func (*Gamepads) Identity

func (g *Gamepads) Identity(id ebiten.GamepadID) (GamepadIdentity, error)

Identity returns what a connected controller claims to be.

type GoStats

type GoStats struct {
	Version    string  `json:"version"`
	Goroutines int     `json:"goroutines"`
	HeapMB     float64 `json:"heap_mb"`
	GCCycles   uint32  `json:"gc_cycles"`
}

GoStats is what the runtime says about itself.

type GoroutinesOutput

type GoroutinesOutput struct {
	Count int `json:"count"`
}

GoroutinesOutput is game_goroutines' answer. The dump itself comes back as text, since it is for reading rather than for parsing.

type Injector

type Injector struct {
	// contains filtered or unexported fields
}

Injector writes synthetic input for the game to read.

It is the layer the tools and the test driver are built on, exposed for a game that wants to drive itself from Go — a demo mode, a replay, a soak test. Reach for Driver in a test and for the MCP tools from an agent; both handle the timing described below, and this does not.

Nothing lands immediately. The wrapper applies whatever is pending inside Update, once per tick, before the game runs — so what is written here is what the game sees on its next tick.

States and events

A key or a button held down, and the cursor's position, are states: they are rewritten every tick until something releases them, so writing one from any goroutine is safe and a tick's delay costs a tick.

Type and Scroll are events. They reach the game for exactly one tick and are then gone, which is what makes them behave like the real thing — and it means writing one from outside the loop races with the tick it was meant for. Use Runtime.Do to write those where the game will see them:

rt.Do(ctx, func() { rt.Injector().Type("hello") })

A build with injection compiled out, or one where the self-check could not confirm the mirror it writes through, leaves every method below doing nothing; Runtime.InputError says which. A zero Injector is a different thing and is not usable — take one from Runtime.Injector.

func (Injector) KeyDown

func (i Injector) KeyDown(key ebiten.Key)

KeyDown presses a key and leaves it pressed.

func (Injector) KeyUp

func (i Injector) KeyUp(key ebiten.Key)

KeyUp releases a key held by KeyDown.

func (Injector) MouseDown

func (i Injector) MouseDown(button ebiten.MouseButton)

MouseDown presses a mouse button and leaves it pressed.

func (Injector) MouseUp

func (i Injector) MouseUp(button ebiten.MouseButton)

MouseUp releases a mouse button.

func (Injector) MoveCursor

func (i Injector) MoveCursor(x, y float64)

MoveCursor puts the cursor at a position in the game's logical pixels, and pins it there until ReleaseCursor.

func (Injector) ReleaseAll

func (i Injector) ReleaseAll()

ReleaseAll drops every held key, button and finger, and unpins the cursor. The releases are stamped properly, so the game sees a real release rather than input that vanished.

func (Injector) ReleaseCursor

func (i Injector) ReleaseCursor()

ReleaseCursor hands the cursor back to whatever the window reports.

func (Injector) Scroll

func (i Injector) Scroll(x, y float64)

Scroll turns the wheel. An event: see the note on Injector.

func (Injector) SetTouches

func (i Injector) SetTouches(touches []Touch)

SetTouches replaces the set of fingers on the screen. Passing none lifts them all; leaving one out lifts that one.

func (Injector) Type

func (i Injector) Type(text string)

Type enters text as characters, the way a text field reads it rather than as key presses. An event: see the note on Injector.

type InputOutput

type InputOutput struct {
	Tick int64 `json:"tick"`

	Keys      []string  `json:"keys,omitempty"`
	Held      []string  `json:"held,omitempty"`
	Released  []string  `json:"released,omitempty"`
	Text      string    `json:"text,omitempty"`
	Touches   int       `json:"touches,omitempty"`
	MovedTo   []float64 `json:"moved_to,omitempty"`
	DraggedTo []float64 `json:"dragged_to,omitempty"`
	Scrolled  []float64 `json:"scrolled,omitempty"`
	Clicked   string    `json:"clicked,omitempty"`
	Holding   string    `json:"holding,omitempty"`
	Button    string    `json:"released_button,omitempty"`
	Cursor    string    `json:"cursor,omitempty"`

	// A gamepad call goes through the same finish, so its answers live here too
	// rather than in a type that would be this one with four fields added.
	Connected    *int   `json:"connected,omitempty"`
	SDLID        string `json:"sdl_id,omitempty"`
	GamepadID    *int   `json:"gamepad_id,omitempty"`
	Disconnected *int   `json:"disconnected,omitempty"`
}

InputOutput is what the tools that drive the game answer with: the tick the input landed in, and a description of what was done.

One type for all of them rather than one each, because every field here is "what did this call do" and a client that wants to know reads the same place whichever tool it called. The empty ones are left out.

type InputStateOutput

type InputStateOutput struct {
	Tick      int64       `json:"tick"`
	Injection InputStatus `json:"injection"`

	KeysPressed  []string       `json:"keys_pressed"`
	MouseButtons []string       `json:"mouse_buttons"`
	Touches      []touchPoint   `json:"touches"`
	Gamepads     []GamepadState `json:"gamepads"`

	Cursor Point  `json:"cursor"`
	Wheel  Offset `json:"wheel"`
}

InputStateOutput is game_input_state's answer: what the game currently believes about its input, read from inside the loop.

type InputStatus

type InputStatus struct {
	Available bool   `json:"available"`
	Reason    string `json:"reason,omitempty"`
}

InputStatus says whether synthetic input works, and why not when it does not.

type Inspect

type Inspect struct {
	// MaxDepth is how far down the tree to walk. Zero means the default.
	MaxDepth int

	// MaxItems caps the elements taken from any one slice, array or map.
	MaxItems int

	// MaxNodes is the total budget, which stops a wide-but-shallow structure
	// from being just as expensive as a deep one.
	MaxNodes int
}

Inspect turns a live value into plain JSON-able data.

It reads unexported fields. That is the whole point: a Go game keeps virtually all of its state unexported, and an inspector that respected visibility would show an empty struct and call it a state dump. Reading is done through reflect.NewAt on an addressable value, which is why the root has to be a pointer to be useful.

The limits are not decoration. A game object reaches half the heap through pointers, so without a depth bound, an item cap and cycle detection the first call would try to serialise the world.

func (Inspect) At

func (in Inspect) At(v any, path string) (any, error)

At walks the value found at a dotted path: "screens.player.pos.x", with "[2]" for slice and array elements.

func (Inspect) Value

func (in Inspect) Value(v any) any

Value walks v and returns something that marshals to JSON.

type InspectOutput

type InspectOutput struct {
	Tick  int64  `json:"tick"`
	Path  string `json:"path"`
	Value any    `json:"value"`
}

InspectOutput is game_inspect's answer.

type LoopOutput

type LoopOutput struct {
	Tick        int64  `json:"tick"`
	Paused      bool   `json:"paused"`
	QueuedSteps int    `json:"queued_steps,omitempty"`
	TPS         int    `json:"tps,omitempty"`
	Waited      string `json:"waited,omitempty"`
	Ran         int64  `json:"ran,omitempty"`
}

LoopOutput is what the tools that hold and release the game answer with.

type Offset

type Offset struct {
	X float64 `json:"x"`
	Y float64 `json:"y"`
}

Offset is a wheel movement.

type Option

type Option func(*Options)

Option customises Options.

func WithAddr

func WithAddr(addr string) Option

WithAddr overrides the listen address, ignoring the environment.

func WithCaptureStage

func WithCaptureStage(stage Stage) Option

WithCaptureStage sets which stage captures come from by default.

StageOffscreen skips the screen-sized copy the final pass needs, which is worth having when that pass is expensive and you are not debugging it. It is not the default, because returning the image from before a game's own final pass — without saying so — is returning something the player never saw.

func WithFactory

func WithFactory(factory func() ebiten.Game) Option

WithFactory supplies a constructor for fresh games.

func WithMediaDir added in v0.2.0

func WithMediaDir(dir string) Option

WithMediaDir puts the artifacts somewhere writable.

The default is relative to the working directory, which an app packaged for a phone does not choose and generally cannot write to. Pass a directory the platform hands the app — Context.getFilesDir() on Android, Documents on iOS.

func WithName

func WithName(name string) Option

WithName sets the name reported to clients.

func WithPanicRecovery added in v0.3.0

func WithPanicRecovery(enabled bool) Option

WithPanicRecovery controls whether panics raised by the game's Update, Draw, Layout and final-screen callbacks are recorded while the MCP server keeps answering. It overrides EBITEN_MCP_RECOVER_PANICS.

func WithState

func WithState(name string, fn StateProvider) Option

WithState publishes a named snapshot of the game, reachable as @name from game_inspect. The provider is handed whatever game is running when it is called, so it keeps working across game_reset.

type Options

type Options struct {
	// Addr is the address the MCP server listens on. Defaults to
	// EBITEN_MCP_ADDR; empty means do not serve.
	Addr string

	// RecoverPanics keeps the server answering after the game panics. It defaults
	// to whether EBITEN_MCP_RECOVER_PANICS is "1"; otherwise a wrapped game
	// preserves Ebitengine's normal panic behaviour.
	RecoverPanics bool

	// Name identifies this game to a client that finds several running.
	Name string

	// Factory builds a fresh game, which is what game_reset and the test driver
	// use to start over. Without it there is nothing to reset to, since
	// Ebitengine's loop cannot be restarted.
	Factory func() ebiten.Game

	// CaptureStage is which stage a capture comes from when it does not ask for
	// one. Defaults to StageFinal, and only means anything for a game that draws
	// its own final screen.
	CaptureStage Stage

	// MediaDir is where screenshots, videos and profiles are written. Empty
	// means the package's MediaDir, relative to the working directory.
	//
	// Which is fine wherever a game is started from a shell, and is the wrong
	// bet inside an app bundle: a working directory that cannot be written to
	// stops newMedia creating the default, Serve returns that error, and Wrap
	// carries on with no server at all. Give it somewhere writable on any
	// platform where you do not choose the working directory. See WithMediaDir.
	MediaDir string

	// States are named snapshots the game publishes, reachable as @name from
	// game_inspect. See WithState.
	States map[string]StateProvider
}

Options configures a wrapped game.

type Percentiles

type Percentiles struct {
	Mean string `json:"mean"`
	P50  string `json:"p50"`
	P95  string `json:"p95"`
	P99  string `json:"p99"`
	Max  string `json:"max"`
}

Percentiles summarises one column of the frame timings. Durations as text, because "1.2ms" is what somebody reading this wants and a nanosecond count is not.

type Point

type Point struct {
	X int `json:"x"`
	Y int `json:"y"`
}

Point is a cursor position in the game's logical pixels.

type ProfileOutput

type ProfileOutput struct {
	Kind     string    `json:"kind"`
	Tick     int64     `json:"tick"`
	Artifact *Artifact `json:"artifact"`
	Note     string    `json:"note,omitempty"`
}

ProfileOutput is game_profile's answer.

type RecordOutput

type RecordOutput struct {
	Frames       int       `json:"frames"`
	Stage        Stage     `json:"stage"`
	FirstTick    int64     `json:"first_tick"`
	LastTick     int64     `json:"last_tick"`
	TicksCovered int64     `json:"ticks_covered"`
	Sheet        *Artifact `json:"contact_sheet"`
	Video        *Artifact `json:"video,omitempty"`

	// Capped says the recording stopped short of what was asked for, and why.
	Capped string `json:"capped,omitempty"`
}

RecordOutput is game_record's answer.

type ResetOutput

type ResetOutput struct {
	Tick  int64 `json:"tick"`
	Reset bool  `json:"reset"`
}

ResetOutput is game_reset's answer.

type RingStatus

type RingStatus struct {
	Enabled bool `json:"enabled"`

	Frames     int     `json:"frames,omitempty"`
	MemoryMB   float64 `json:"memory_mb,omitempty"`
	BudgetMB   float64 `json:"budget_mb,omitempty"`
	Every      int     `json:"every,omitempty"`
	Stage      Stage   `json:"stage,omitempty"`
	Encoded    int64   `json:"encoded,omitempty"`
	Dropped    int64   `json:"dropped,omitempty"`
	OldestTick int64   `json:"oldest_tick,omitempty"`
	NewestTick int64   `json:"newest_tick,omitempty"`
	Note       string  `json:"note,omitempty"`
}

RingStatus is what the retrospective buffer is doing. Reported by game_state as well, so that a buffer throwing away half of what it is given is visible rather than something to deduce from tick numbers.

type Runtime

type Runtime struct {
	// contains filtered or unexported fields
}

Runtime is the state behind a wrapped game: the tick counter, the command queue, the input injector and the captures.

Everything that reads or writes game state has to go through Do, which runs it inside Update. Everything that describes the runtime itself is readable from any goroutine at any time, on purpose: those are exactly the questions worth asking when the loop has stopped answering.

func Wrap

func Wrap(game ebiten.Game, opts ...Option) (ebiten.Game, *Runtime)

Wrap returns the game to hand to Ebitengine and the runtime that drives it.

Use it when the game is started by something other than RunGame — a custom runner, a mobile entry point, a test. RunGame is the same thing plus the call to ebiten.RunGame.

The server starts here rather than in RunGame so that a custom runner gets it too. Calling Wrap explicitly always installs the wrapper, because its Runtime is useful to custom runners and tests even without a server. RunGame bypasses the wrapper when no server is available.

func (*Runtime) Capture

func (r *Runtime) Capture(ctx context.Context) (*Frame, error)

Capture asks for the next frame the game draws, at whichever stage this runtime is configured for.

Reading pixels back is a synchronisation point with the GPU, so it only happens when somebody is waiting for one. Capturing every frame unconditionally would cost the game a large part of its frame budget for images nobody looks at.

func (*Runtime) CaptureStage

func (r *Runtime) CaptureStage(ctx context.Context, stage Stage) (*Frame, error)

CaptureStage asks for the next frame at a particular stage.

Asking for the offscreen of a game that has a final pass is how you tell whether a visual bug is in the game or in the pass — and it is what tests should use, since the final screen is the size of the window and a golden that changes with the monitor is no golden at all.

func (*Runtime) Close

func (r *Runtime) Close() error

Close stops the MCP server, puts stdout and stderr back, drops the frame buffer and unplugs any virtual controllers. The game is untouched.

Two of those outlive the process if they are skipped, which is why this is not optional and why RunGame and RunTests both call it:

  • A uinput device belongs to the kernel, not to the process that asked for it, so leaving without destroying one leaves a phantom gamepad on the machine.
  • Trace capture redirects descriptors 1 and 2 through a pipe. Until they are put back, everything written to them reaches the terminal only by way of a goroutine — and os.Exit does not wait for goroutines, so whatever was written last is lost. For a test binary that is the failure message.

Safe to call twice; the second one has nothing to do.

func (*Runtime) Crash

func (r *Runtime) Crash() *Crash

Crash returns the panic the game died of, or nil if it is still alive.

func (*Runtime) DefaultStage

func (r *Runtime) DefaultStage() Stage

DefaultStage is the stage Capture uses when nobody asks for one.

func (*Runtime) Do

func (r *Runtime) Do(ctx context.Context, fn func()) error

Do runs fn inside the game loop, at the point the game's own Update would run, and waits for it to finish.

Every caller must pass a deadline. A game that has crashed or deadlocked never drains the queue, and a debugging tool that hangs along with the thing it is debugging is worse than useless.

func (*Runtime) Gamepads

func (r *Runtime) Gamepads() *Gamepads

Gamepads returns the handle for virtual controllers.

func (*Runtime) HasFinalPass

func (r *Runtime) HasFinalPass() bool

HasFinalPass reports whether the game draws its own final screen, which is what makes the two stages different images.

func (*Runtime) Injector

func (r *Runtime) Injector() Injector

Injector is the handle used to synthesise input. It does nothing when injection is unavailable; InputError says why.

func (*Runtime) InputError

func (r *Runtime) InputError() error

InputError reports why input injection is unavailable, or nil when it works. It is only meaningful after the first tick, which is when the layout self-check runs.

func (*Runtime) LastFrame

func (r *Runtime) LastFrame() *Frame

LastFrame is the most recently captured frame, or nil if nothing has been captured yet. Note that this is the last frame somebody asked for, not the last frame drawn: nothing is captured behind the caller's back.

func (*Runtime) LastTick

func (r *Runtime) LastTick() time.Time

LastTick is when the game last completed a tick. Compared against now, it is the cheapest way to tell a paused game from a wedged one.

func (*Runtime) Pause

func (r *Runtime) Pause()

Pause stops calling the game's Update. Draw keeps running, so the last frame stays on screen and can still be captured.

func (*Runtime) Paused

func (r *Runtime) Paused() (paused bool, steps int)

Paused reports whether the game is currently held.

func (*Runtime) RegisterState

func (r *Runtime) RegisterState(name string, fn StateProvider)

RegisterState publishes a named snapshot. Registering the same name twice replaces it.

func (*Runtime) Reset

func (r *Runtime) Reset(ctx context.Context) error

Reset replaces the running game with a new one from the factory given at construction. It fails if there is no factory.

func (*Runtime) Resume

func (r *Runtime) Resume()

Resume undoes Pause.

func (*Runtime) Ring

func (r *Runtime) Ring() *frameRing

Ring is the retrospective frame buffer, building it on the first ask.

It is a constructor as much as a getter, which matters because game_state reports the buffer's status: merely asking what the game is doing brings the buffer into being. Empty and disabled, so it costs an allocation and nothing else, but it is why nothing downstream needs to handle a nil one.

func (*Runtime) Server

func (r *Runtime) Server() *Server

Server returns the MCP server serving this game, or nil when none was started.

func (*Runtime) SetGame

func (r *Runtime) SetGame(ctx context.Context, game ebiten.Game) error

SetGame replaces the running game with a new one at the next tick boundary.

This exists because ebiten.RunGame cannot be called twice in a process, so "start over" cannot mean restarting the loop. Swapping the game the wrapper holds is the only way to get a fresh one, and it is what makes independent tests possible inside a single test binary.

func (*Runtime) Step

func (r *Runtime) Step(n int)

Step runs exactly n more ticks and pauses again.

func (*Runtime) Terminate

func (r *Runtime) Terminate()

Terminate ends the game loop cleanly, as if the game had returned ebiten.Termination.

It deliberately ignores pause, steps and a recorded crash: a game held or wedged in any of those states still has to be able to shut down, and it is the only way out for a test binary that owns the process's single loop.

func (*Runtime) Tick

func (r *Runtime) Tick() int64

Tick is the number of ticks the wrapped game has actually run. It is not Ebitengine's tick: a paused game keeps being ticked by the engine while this counter stands still, which is what makes stepping meaningful.

func (*Runtime) Timings

func (r *Runtime) Timings() []FrameTiming

Timings returns the most recent frame timings, oldest first.

func (*Runtime) UnregisterState

func (r *Runtime) UnregisterState(name string)

UnregisterState removes one.

func (*Runtime) Uptime

func (r *Runtime) Uptime() time.Duration

Uptime is how long the game has been running.

func (*Runtime) WaitTicks

func (r *Runtime) WaitTicks(ctx context.Context, n int) error

WaitTicks blocks until the game has advanced n ticks. A paused game advances none, so this is also how a caller notices it is paused.

It counts against a target tick rather than counting wake-ups, and that is not a detail. Waiting for the channel n times loses any tick that lands between one wake-up and the next read of the channel, which with vsync off is most of them — so waiting for four hundred ticks would sit there long after four hundred had gone by, and report a stalled loop that had done exactly what it was asked. Reading the tick and the channel under the same lock closes that: a tick that arrives in between has either already been counted or will close the channel now held.

type Screen

type Screen struct {
	Width          int   `json:"width"`
	Height         int   `json:"height"`
	CapturedAtTick int64 `json:"captured_at_tick"`
}

Screen is the last frame's size and when it was taken.

type ScriptOutput

type ScriptOutput struct {
	Steps    int          `json:"steps"`
	Ticks    int64        `json:"ticks"`
	Tick     int64        `json:"tick"`
	Captured int          `json:"captured"`
	Log      []ScriptStep `json:"log"`
	Sheet    *Artifact    `json:"contact_sheet,omitempty"`
}

ScriptOutput is game_script's answer.

type ScriptStep

type ScriptStep struct {
	Step  int    `json:"step"`
	At    int    `json:"at"`
	Tick  int64  `json:"tick"`
	Label string `json:"label,omitempty"`
}

ScriptStep is one line of a script's log: which step ran, when it was meant to, and when it did.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server exposes one running game over MCP.

func Serve

func Serve(rt *Runtime, opts *Options) (*Server, error)

Serve starts the MCP server for a wrapped game.

The listener is bound before returning, so the address in the result is the real one even when the requested port was taken.

func (*Server) Addr

func (s *Server) Addr() string

Addr is the address the server actually bound.

func (*Server) Close

func (s *Server) Close() error

Close stops serving. The game keeps running.

Nil-safe on the HTTP server because newServer builds one without a listener — that is the whole point of it — and closing such a server has to be a no-op rather than the one thing in this package that panics on tidying up.

func (*Server) URL

func (s *Server) URL() string

URL is the MCP endpoint to put in an .mcp.json.

type Size

type Size struct {
	Width  int `json:"width"`
	Height int `json:"height"`
}

Size is a width and a height in pixels.

type Stage

type Stage string

Stage is which of Ebitengine's two drawing steps a frame was read from.

Ebitengine draws twice. First the game's Draw fills an offscreen at the logical resolution. Then, if the game implements ebiten.FinalScreenDrawer, its DrawFinalScreen composites that offscreen onto the real screen — and a CRT shader, scanlines, a letterbox or a custom scaling filter all live in that second step. Reading the offscreen would show the image before any of it, which is not what the player is looking at.

const (
	// StageOffscreen is the image the game's own Draw produced, at the logical
	// resolution.
	StageOffscreen Stage = "offscreen"

	// StageFinal is what the player sees, after the game's own final pass, at
	// the resolution of the window.
	StageFinal Stage = "final"
)

type StateOutput

type StateOutput struct {
	Name          string `json:"name"`
	Loop          string `json:"loop"`
	Tick          int64  `json:"tick"`
	SinceLastTick string `json:"since_last_tick"`
	Uptime        string `json:"uptime"`
	Paused        bool   `json:"paused"`
	QueuedSteps   int    `json:"queued_steps"`

	TPS       int     `json:"tps"`
	ActualTPS float64 `json:"actual_tps"`
	ActualFPS float64 `json:"actual_fps"`
	VSync     bool    `json:"vsync"`

	Window            Size    `json:"window"`
	DeviceScaleFactor float64 `json:"device_scale_factor"`
	Screen            *Screen `json:"screen,omitempty"`

	Input          InputStatus `json:"input"`
	Go             GoStats     `json:"go"`
	StateProviders []string    `json:"state_providers"`
	MediaDir       string      `json:"media_dir"`
	FrameRing      RingStatus  `json:"frame_ring"`

	Crash       *Crash      `json:"crash,omitempty"`
	CrashTraces []TraceLine `json:"crash_traces,omitempty"`
}

StateOutput is game_state's answer: how the game and the process are doing.

A struct with three optional fields, not a map. It looked like a map's job because so much goes in it, but nothing about its shape depends on what the tool found — only its values do, and a field that is sometimes absent is what omitempty is for.

type StateProvider

type StateProvider func(game ebiten.Game) any

StateProvider is a named view a game publishes of itself, so an inspection can return "the podium, as the game understands it" rather than a struct dump the caller has to interpret.

It is handed the game that is running now. That is the whole reason this is not a plain func() any: the registry used to be global and the function captured whatever game existed when it was written, so after a game_reset built a fresh one, @summary went on describing the object nobody was playing any more — and looked entirely plausible doing it.

It runs inside the game loop, so it can read state without locking, and it must not block.

type TPSOutput

type TPSOutput struct {
	TPS int `json:"tps"`
}

TPSOutput is game_set_tps' answer.

type Touch

type Touch struct {
	// ID identifies this finger across calls, the way a real touch keeps its
	// id from the moment it lands until it lifts.
	ID int
	X  int
	Y  int
}

Touch is one synthetic finger on the screen, in the game's logical pixels.

type TraceLine

type TraceLine struct {
	Tick   int64     `json:"tick"`
	Time   time.Time `json:"time"`
	Stream string    `json:"stream"`
	Text   string    `json:"text"`
}

TraceLine is one line the process wrote.

type TracesOutput

type TracesOutput struct {
	Lines []TraceLine `json:"lines"`
	Tick  int64       `json:"tick"`
	Total int         `json:"total"`

	// Unavailable says why these lines are not everything the process wrote:
	// no capture at all on a platform without descriptor duplication, or one
	// of the two streams missing. Absent when the capture is whole.
	Unavailable string `json:"unavailable,omitempty"`
}

TracesOutput is game_traces' answer.

type Truncated

type Truncated struct {
	Truncated string `json:"__truncated"`
}

Truncated marks where the walk stopped, so a reader can tell a value that is genuinely absent from one that was too expensive to include.

type WaitOutput

type WaitOutput struct {
	Tick    int64  `json:"tick"`
	Value   any    `json:"value,omitempty"`
	Was     any    `json:"was,omitempty"`
	Matched string `json:"matched,omitempty"`
	Waited  string `json:"waited,omitempty"`
}

WaitOutput is game_wait's answer when the condition was met.

Directories

Path Synopsis
cmd
ebitenmcp command
Command ebitenmcp is everything around a game that the game cannot do for itself: give it a display, start it, wire it into an agent, and talk to it from a shell.
Command ebitenmcp is everything around a game that the game cannot do for itself: give it a display, start it, wire it into an agent, and talk to it from a shell.
examples
playground command
Command playground is the test bed for go-ebiten-mcp.
Command playground is the test bed for go-ebiten-mcp.
internal
hook
Package hook reaches into Ebitengine's internals to write synthetic input into the state the game reads every tick.
Package hook reaches into Ebitengine's internals to write synthetic input into the state the game reads every tick.
uinput
Package uinput creates a virtual gamepad the operating system treats as real.
Package uinput creates a virtual gamepad the operating system treats as real.
upstream
Package upstream records what this module assumes about Ebitengine's internals, and nothing else.
Package upstream records what this module assumes about Ebitengine's internals, and nothing else.
wire
Package wire holds the handful of things the library and the command line both have to agree on.
Package wire holds the handful of things the library and the command line both have to agree on.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL