Inkwell is a point-and-click adventure framework for Go, built on Ebitengine. You register plain struct literals into managers; the library does input, rendering, dialog trees, cutscenes, HUD, themes and lazy asset loading. Your code only declares content.
Manager.Register.Seq, Par, If, Wait, Say, Walk, … with lazy conditions over flags, vars and inventory.Module path: git.teletypegames.org/engines/inkwell
The demo game "Morning Coffee" lives in inkwell-demo and consumes the library as a normal Go module.
Needs Go 1.24+ (generic type aliases) and an OpenGL context.
go mod init my-game
go get git.teletypegames.org/engines/inkwell
If HTTPS to the private host fails, use SSH:
git config --global \
url."ssh://git@git.teletypegames.org:2222/".insteadOf "https://git.teletypegames.org/"
export GOPRIVATE=git.teletypegames.org/*
A minimum game:
package main
import (
"log"
"git.teletypegames.org/engines/inkwell"
)
func main() {
g := inkwell.NewGame("Sample", 320, 200)
g.AssetManager.Register(inkwell.Asset{
Name: "bg/room", Path: "assets/bg/room.png", Kind: inkwell.AssetImage,
})
g.CharacterManager.Register(inkwell.Character{Name: "player", W: 28, H: 62})
g.SceneManager.Register(inkwell.Scene{
Name: "room",
Background: "bg/room",
Actors: []inkwell.SceneActor{{CharacterName: "player", At: inkwell.Point{X: 160, Y: 140}}},
Hotspots: []inkwell.Hotspot{{
Name: "door",
Area: inkwell.Rect(260, 50, 40, 90),
Label: "door",
OnLook: inkwell.Say("player", "A wooden door."),
}},
})
g.StartAt("room")
inkwell.RegisterDefaultUI(g) // optional; Run calls it if no widgets are registered
if err := inkwell.Run(g); err != nil {
log.Fatal(err)
}
}
Every name-addressable entity goes through one generic type:
// core.manager.go
type Named interface {
GetName() string
}
type Manager[T Named] struct { /* ... */ }
type ItemManager = Manager[Item]
type SceneManager = Manager[Scene]
type CharacterManager = Manager[Character]
type DialogueManager = Manager[Dialogue]
type ScriptManager = Manager[Script]
type AssetManager = Manager[Asset]
type VerbManager = Manager[Verb]
type UIManager = Manager[Widget]
type ThemeManager = Manager[Theme]
| Method | Behaviour |
|---|---|
Register(v T) |
Adds v. Panics on empty or duplicate Name. |
Set(v T) |
Replaces an entry in place, keeping its position; registers it when the name is new. |
Get(name) (T, bool) |
Lookup; false if absent. |
MustGet(name) T |
Same, but panics on a missing name. |
Has(name) bool |
True if registered. |
Len() int |
Number of entries. |
Names() []string |
Names in insertion order (widget Z-order uses this). |
All() []T |
Every entry in insertion order. |
SortedNames() []string |
Names alphabetically. |
Each(fn func(T)) |
Iterate in insertion order. |
Remove(name string) |
Drop a registration; no-op if unknown. |
Every registerable struct implements GetName(). An optional TypeLabel() makes panic messages clearer:
func (i Item) GetName() string { return i.Name }
func (i Item) TypeLabel() string { return "item" }
Duplicates panic on purpose: a duplicate registration is a construction-time bug. Accepting it quietly would shadow an entity at runtime.
When a registration genuinely has to be rewritten — a derived field filled in after the fact, a hot-reloaded entity — Set is the deliberate overwrite. It keeps the entry's place in the insertion order, so widget Z-order and any other order-sensitive iteration survive the rewrite.
// core.game.go
type Game struct {
Title string
Width, Height int
ItemManager *ItemManager
SceneManager *SceneManager
CharacterManager *CharacterManager
DialogueManager *DialogueManager
ScriptManager *ScriptManager
AssetManager *AssetManager
VerbManager *VerbManager
UIManager *UIManager
ThemeManager *ThemeManager
State *State
Inventory *Inventory
Audio *AudioPlayer
Camera *Camera
Input *Input
SceneRect Rectangle // the part of the window the picture occupies
ExitLook func(Exit) Action // default look response for generated exits
ExitTake func(Exit) Action // default take response for generated exits
MaxLogLines int
}
NewGame(title, w, h) creates empty managers, registers the four SCUMM verbs (look, use, talk, take) and all four preset themes, and selects classic-scumm. Widgets are not auto-registered — call RegisterDefaultUI(g), or let Run do it when UIManager is empty.
The manager fields are ordinary *Manager values, so a domain that keeps its own registries can assign them onto the game instead of copying every entry across:
g := inkwell.NewGame("Real World", 640, 380)
g.SceneManager = mySceneManager
g.AssetManager = myAssetManager
Do it before Run — that is where the engine wires up the parts that hold a registry directly. ThemeManager and VerbManager are worth a second thought: they arrive holding the presets and the SCUMM verbs, and replacing either throws that away. Register into them instead unless that is what you want.
func (g *Game) StartAt(name string) *Game // entry scene
func (g *Game) OnStart(script string) *Game // script run after the scene's OnEnter
func (g *Game) OnFinale(script string) *Game // closing script, queued after OnStart
func (g *Game) Validate() error // cross-check name references
func (g *Game) Run() error // same as inkwell.Run(g)
func (g *Game) CurrentScene() string // where the player is
func (g *Game) PreviousScene() string // where they came from; Back() goes here
func (g *Game) SceneArea() Rectangle // SceneRect, or the whole window
func (g *Game) SceneHotspots(name string) []Hotspot
func (g *Game) Theme() Theme
func (g *Game) UseTheme(name string) // runtime switch, next frame uses it
OnStart and OnFinale name registered scripts rather than taking an action, so the opening a game plays is content like any other — a Script in the ScriptManager, reachable by name and editable without touching the wiring. Validate rejects a name that is not registered. OnFinale is queued directly after the start script, which is what a "boot straight into the ending" debug flag wants:
g.StartAt(start)
g.OnStart(ScriptOpening)
if finale {
g.OnFinale(ScriptFinale)
}
All three return *Game, so they chain at the end of Build.
SceneHotspots is a scene's own hotspots followed by one per Exit, cached per scene; authored hotspots come first, so a painted thing beats the edge strip an exit sits on. CurrentScene and PreviousScene both survive a save.
UI runtime state lives on Game: actions and the engine write it, widgets read it.
func (g *Game) SelectedVerb() string
func (g *Game) SetSelectedVerb(s string)
func (g *Game) HoverLabel() string // raw target label
func (g *Game) SetHoverLabel(s string)
func (g *Game) HoverHint() string // composed "verb + target"
func (g *Game) SetSpeech(speaker, text string)
func (g *Game) ClearSpeech()
func (g *Game) FlashLine(text string) // ~2s status banner
func (g *Game) CharacterInScene(name string) bool // CharacterPanel auto-hide
func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color)
DrawText is one indirection, so the library can move to text/v2 later without touching widgets.
The chat log is a ring buffer; bound it with Game.MaxLogLines (0 = unbounded).
type LogKind int
const (
LogAction // "> look at door" — player commands
LogResponse // in-world replies, Say output
LogSystem // meta
)
type LogMessage struct {
Speaker string
Text string
Kind LogKind
}
func (g *Game) LogAction(text string)
func (g *Game) LogResponse(speaker, text string)
func (g *Game) LogSystem(text string)
func (g *Game) Messages() []LogMessage
// util.geometry.go
type Point struct{ X, Y float64 }
func (p Point) Add(q Point) Point
func (p Point) Sub(q Point) Point
func (p Point) Dist(q Point) float64
type Shape interface {
Contains(p Point) bool
Bounds() Rectangle
}
type Rectangle struct{ X, Y, W, H float64 }
func Rect(x, y, w, h float64) Rectangle
func (r Rectangle) Contains(p Point) bool
func (r Rectangle) Bounds() Rectangle
func (r Rectangle) Center() Point
type Polygon struct{ Points []Point }
func Poly(pts ...Point) Polygon
func (g Polygon) Contains(pt Point) bool // ray-casting
func (g Polygon) Bounds() Rectangle // axis-aligned bbox
Both shapes work as a Hotspot.Area. Hotspots are hit-tested with Shape.Contains every frame.
// asset.def.go
type AssetKind int
const (
AssetImage AssetKind = iota
AssetAudio
AssetFont
)
type Asset struct {
Name string // logical id, "bg/kitchen"
Path string // "assets/bg/kitchen.png"
Kind AssetKind
}
g.AssetManager.Register(inkwell.Asset{
Name: "bg/kitchen", Path: "assets/bg/kitchen.png", Kind: inkwell.AssetImage,
})
Register only stores the spec. The file opens on first use, and a missing file becomes a deterministic colored placeholder — so the game runs with no art at all.
// scene.def.go
type Scene struct {
Name string
Title string // human-readable, read by TopBar
Background string // Asset.Name
Music string // Asset.Name, optional
Hotspots []Hotspot
Exits []Exit // connections to other scenes
Walkboxes []Polygon
Triggers []Trigger
Actors []SceneActor
OnEnter Action
OnLeave Action
}
type SceneActor struct {
CharacterName string
At Point
}
OnEnter is queued when the scene becomes current (after the fade-in), OnLeave on the way out.Actors says which registered characters stand in the scene, and where their feet start.Walkboxes constrain movement. With walkboxes, Walk routes with a BFS over the polygon adjacency graph (polygons sharing an edge are neighbours, the shared edge midpoint is a waypoint), and destinations outside every box are clipped to the nearest boundary. With no walkboxes the character walks straight.Exits are the scene's connections to other scenes, declared as data. The engine turns each one into a hotspot — see below.// scene.exit.go
type Exit struct {
To string // target scene; empty means "back the way you came"
Label string // what the status line calls it
Side ExitSide // where it sits when Area is nil
Area Shape // overrides the edge strip Side would give it
Needs string // flag that has to be set before it opens
Blocked Action // what happens while it is not
OnLook Action // overrides Game.ExitLook for this exit
OnTake Action // overrides Game.ExitTake for this exit
}
type ExitSide int
const (
ExitLeft ExitSide = iota // off the left edge
ExitRight // off the right edge
ExitBack // into the depth of the picture
ExitNear // out towards the camera
)
func ExitName(to string) string
A connection belongs to the scene it leads out of, so it is written in that scene's own literal instead of a map kept somewhere else:
Scene{
Name: "alley",
Exits: []Exit{
{To: "noodle_house", Label: "back out to the street", Side: ExitLeft},
{To: "", Label: "the fire escape", Side: ExitBack,
Needs: "has_ladder", Blocked: Say("paul", "Can't reach it.")},
},
}
The engine expands each exit into a Hotspot: Cursor: CursorExit, the exit's Label, and OnUse bound to GoTo(To) — or Back() when To is empty. With Needs set, the whole travel is wrapped in If(Flag(Needs), travel, Blocked), so the exit is visible either way: a locked door still says it is a door.
Area, an exit is a strip along one edge of the picture, sized as a fraction of Game.SceneArea() — the convention every 1990s point & click used, and one field to re-aim at a real door once the artwork is measured. A game whose HUD covers the foot of the window sets Game.SceneRect, so the strips land inside the painting instead of under the HUD.Game.ExitLook and Game.ExitTake supply the look and take responses for every generated exit, so they are not repeated per exit.ExitName(To) — "exit:noodle_house", or "exit:back" — so the location graph can be read straight back out of the registered scenes, and Validate rejects an exit naming a scene nobody registered.// scene.hotspot.go
type Hotspot struct {
Name string
Area Shape // Rectangle or Polygon
Label string // hover hint
Cursor CursorKind
OnLook Action
OnUse Action
OnTalk Action
OnTake Action
OnGive Action
OnUseWith map[string]Action // item Name -> action
OnVerb map[string]Action // custom verb Name -> action
}
type CursorKind int
const (
CursorDefault CursorKind = iota
CursorLook
CursorUse
CursorTalk
CursorTake
CursorExit
)
Hotspots belong to a scene, not to a manager. A click resolves via hotspot.handler(verbName): the four built-in verbs map to the On* fields, everything else falls back to OnVerb[verbName].
// scene.trigger.go
type Trigger struct {
Name string
When Condition
Do Action
Once bool
}
Triggers arm on scene enter. On every idle frame (script slot free) the engine samples When and queues Do on a rising edge (false → true). Once: true fires at most once per arming. A nil When or Do is skipped.
Triggers are not sampled while a cutscene or dialog runs. The edge survives that busy window and is detected on the next idle frame. Load resets triggers to "freshly armed", like scene re-entry.
// item.def.go
type Item struct {
Name string
Sprite string // Asset.Name
Description string
OnUseSelf Action
OnUseWith map[string]Action // target: hotspot Name or item Name
}
Give("key") appends the item to the inventory. Clicking a hotspot with an item selected resolves in this order:
hotspot.OnUseWith[item.Name]item.OnUseWith[hotspot.Name]// item.inventory.go
func NewInventory() *Inventory
func (i *Inventory) Add(name string)
func (i *Inventory) Remove(name string)
func (i *Inventory) Has(name string) bool
func (i *Inventory) Select(name string) // "" clears
func (i *Inventory) Selected() string
func (i *Inventory) Items() []string // copy
Runtime state owned by Game, not a manager. Mutated by Give / TakeAway and by clicks on InventoryBar.
// actor.def.go
type Character struct {
Name string
Sprite string // Asset.Name
Animations map[string]AnimationClip
Speed float64 // px/sec, default 60
SpeechColor color.Color
Start Point
W, H float64 // placeholder size hint
}
type AnimationClip struct {
Frames []Rectangle // source rects in the sprite sheet
FrameTime float64
Loop bool
}
W < H, quadruped otherwise. An RGBA SpeechColor also colors the placeholder body and the bubble text."walk" while a path is active, "idle" otherwise. With only one registered, it plays in both states.FrameTime <= 0 or empty Frames disables a clip; then the whole sprite (or the placeholder) is drawn.SubImage and scaled to W×H. Loop: false freezes on the last frame.// dialog.def.go
type Dialogue struct {
Name string
Start string // initial node; "" = first in slice
Nodes []DialogueNode
}
type DialogueNode struct {
Name string
Lines []DialogueLine // one per click
Choices []DialogueChoice
}
type DialogueLine struct {
Speaker string // Character.Name
Text string
}
type DialogueChoice struct {
Text string
Show Condition // nil = always visible
Once bool // hide after the first pick
Actions []Action
}
func (d Dialogue) Node(name string) (DialogueNode, bool)
The flow: RunDialogue("name") blocks until the dialog closes → DialogBox shows one line per click → after the last line the visible Choices appear → picking one runs Seq(choice.Actions...) and, for Once, records it with State.NoteTalked. GotoNode("other") jumps inside the dialogue, EndDialogue() closes it.
// action.script.go
type Script struct {
Name string
Actions Action // usually Seq(...)
}
A named composite action, for reusing a cutscene from several call sites. Run it with RunScript("name").
// ui.verb.go
type Verb struct {
Name string // canonical id, "look"
Label string // display label, "Nézd"
Default Action // runs when the hotspot has no handler
}
g.VerbManager.Register(inkwell.Verb{
Name: "push", Label: "Lökd",
Default: inkwell.Say("player", "Nem mozdul."),
})
The verb bar and verb wheel read VerbManager.Names() every frame, so a verb added at runtime appears immediately.
// action.def.go
type Action interface {
Start() Runner
}
type Runner interface {
Tick(ctx *Ctx) Status
}
type Status int
const (
StatusRunning Status = iota
StatusDone
StatusFailed
)
type Ctx struct {
Game *Game
DT float64 // seconds since last tick
Scene *Scene // current scene, may be nil
Hotspot *Hotspot
Item *Item
}
An Action is an immutable spec — safe to keep in a Hotspot.OnLook field and reuse on every click. A Runner is one execution with its own state; the engine always asks for a fresh one.
Only one runner is active (the "script slot"). Actions queued while one runs are dropped and logged. For parallel work use Par(...) inside a single action.
StatusDone advances a Seq. StatusFailed short-circuits the whole branch — this is how RequireItem aborts a sequence.
| Constructor | Behaviour |
|---|---|
Seq(a ...Action) |
Children in order. Nested Seq are flattened, nil ignored. |
Par(a ...Action) |
Children together; done when all are done. |
If(c Condition, then Action, els ...Action) |
then when c is true, otherwise Seq(els...). |
Wait(seconds float64) |
Block for seconds. |
Say(speaker, text string) |
Line above the speaker + chat-log entry. Click to skip. Duration scales with length, 1.2s floor. |
GoTo(scene string) |
Switch scene with a fade. |
Back() |
Return to PreviousScene(). No-op when there is nowhere to go back to. |
Walk(character string, to Point) |
Move at Character.Speed; done on arrival. |
Give(item string) |
Add to inventory. |
TakeAway(item string) |
Remove from inventory. |
RequireItem(item string) |
Missing item → flash "Ehhez kell egy …" and fail. |
SetFlag(name) / ClearFlag(name) |
State.SetFlag / State.ClearFlag. |
SetVar(name string, v any) |
State.SetVar. |
PlayMusic(asset) / StopMusic() / PlaySound(asset) |
AudioPlayer calls. |
RunDialogue(name string) |
Start a dialogue; blocks until it closes. |
EndDialogue() |
Close the active dialog. |
GotoNode(node string) |
Jump inside the active dialog. |
RunScript(name string) |
Run a registered Script. |
ShowEnd(text string) |
End-card overlay; never finishes. |
Custom(fn func(*Ctx) Status) |
Escape hatch for an arbitrary callback. |
For long-running state (animation, tween, poll) implement both interfaces:
type fadeAction struct{ target string; duration float64 }
func (a *fadeAction) Start() Runner { return &fadeRunner{spec: a} }
type fadeRunner struct {
spec *fadeAction
elapsed float64
}
func (r *fadeRunner) Tick(ctx *Ctx) Status {
r.elapsed += ctx.DT
if r.elapsed >= r.spec.duration {
return StatusDone
}
return StatusRunning
}
For a one-shot mutation, Custom is shorter:
bumpScore := inkwell.Custom(func(ctx *inkwell.Ctx) inkwell.Status {
cur := ctx.Game.State.Var("score").(int)
ctx.Game.State.SetVar("score", cur+10)
return inkwell.StatusDone
})
// action.condition.go
type Condition interface {
Eval(ctx *Ctx) bool
}
| Constructor | Meaning |
|---|---|
Not(c) |
!c |
And(cs ...) |
all true |
Or(cs ...) |
at least one true |
Flag(name) |
State.Flag(name) |
HasItem(name) |
Inventory.Has(name) |
SelectedItem(name) |
Inventory.Selected() == name |
InScene(name) |
current Ctx.Scene matches |
VarEq(name, v) |
State.Var(name) == v, Go equality |
Conditions are stateless and evaluated lazily, including inside If and DialogueChoice.Show.
// state.def.go
func NewState() *State
func (s *State) Flag(name string) bool
func (s *State) SetFlag(name string)
func (s *State) ClearFlag(name string)
func (s *State) Var(name string) any
func (s *State) SetVar(name string, v any)
func (s *State) Visited(name string) int
func (s *State) NoteVisit(name string) // engine bumps on scene enter
func (s *State) Talked(node string) int
func (s *State) NoteTalked(node string) // DialogBox bumps on a Once pick
Flags carry puzzle state ("cupboard_open"). SetVar takes any value — strings, numbers for scores, struct references. TopBar reads vars by name for score and time. Visited and Talked back the "Once" semantics.
The HUD is a tree of widgets registered into g.UIManager. Built-ins cover the classic adventure UI; your own widgets plug in without touching the library.
// ui.widget.go
type Widget interface {
Named
Tick(ctx *UICtx)
Draw(dst *ebiten.Image, ctx *UICtx)
}
type UICtx struct {
Game *Game
DT float64
}
type MouseButton int
const (
MouseButtonLeft MouseButton = iota
MouseButtonRight
)
type Size struct{ W, H int }
type Align int
const (
AlignLeft Align = iota
AlignCenter
AlignRight
)
A widget handles its own input, hit-testing, state and drawing. There is no layout layer — widgets carry their own Bounds (or compute them, like RadialVerbs).
Tick runs in reverse registration order, so the widget on top gets the click first. It claims the event with ctx.Game.Input.ConsumeLeft() / ConsumeRight(); later widgets then see LeftClicked() == false.Draw runs in registration order, so the last registered is painted on top.Cursor is registered last by convention: always on top, never claims clicks.handleSceneInput (hotspot interactions). A consumed click makes that a no-op.One file each, ui.<name>.go. Zero-value fields fall back to sensible defaults.
// ui.panel.go — colored rectangle, optional border; backdrop for other widgets
type Panel struct {
Name string
Bounds Rectangle
BG color.Color // nil -> Theme.PanelBG
Border int // 0 = none
BorderColor color.Color // nil -> Theme.DialogBorder
}
// ui.status.go — one line: the active flash message, else the hover hint
type StatusLine struct {
Name string
Y int
Align Align // default AlignCenter
ScreenWidth int // 0 -> Game.Width
}
// ui.verb_bar.go — SCUMM verb panel, Cols x ceil(N/Cols) grid from VerbManager.Names()
type VerbBar struct {
Name string
Origin Point
Cols int // default 2
Button Size
Gap Point
PanelBG bool
}
// ui.verb_radial.go — verb coin or permanent wheel
type RadialVerbs struct {
Name string
Trigger MouseButton // default MouseButtonRight
Radius float64 // default 40
AlwaysVisible bool // permanent wheel at Center; Trigger ignored
Center Point
Labels map[string]string // shorten a label so it fits the disk
}
// ui.inventory.go — item slots; hover sets the description, click toggles selection
type InventoryBar struct {
Name string
Origin Point
Slots int
Cols int // 0 -> Slots (single row)
SlotSize int
Gap int
PanelBG bool
}
// ui.speech.go — draws whatever Game.SetSpeech last set, above the speaker's head
type SpeechBubble struct {
Name string
MaxWidth int // wrap width, default 200
Padding int // default 3
OffsetY int // lift above head
FallbackY int // anchor when the speaker is unknown
}
// ui.dialog_box.go — dormant unless a dialogue runs; then consumes every click
type DialogBox struct {
Name string
Bounds Rectangle // default Rect(0, 140, Width, 60)
LineHeight int // default 14
Padding int // default 6
}
// ui.end_card.go — fullscreen overlay from ShowEnd; consumes every click
type EndCard struct{ Name string }
// ui.cursor.go — crosshair, or the selected item's sprite when it has one
type Cursor struct{ Name string }
// ui.hotspot_debug.go — outlines every hotspot in the scene
type HotspotDebug struct {
Name string
Enabled bool
ToggleKey ebiten.Key // 0 -> ebiten.KeyF1
}
// ui.top_bar.go — left: LeftText or Scene.Title | center: score | right: time
type TopBar struct {
Name string
Height int // default 12
LeftText string
ScoreVar string // State.Var key; "" = no score
ScoreMax int // >0 -> "Score: X/MAX"
TimeVar string // State.Var key; "" = no time
}
// ui.character_panel.go — portrait, role badge, stat rows;
// auto-hides when the character is not an actor in the current scene
type CharStat struct {
Label string
VarKey string // State.Var(VarKey), fmt-printed
}
type CharacterPanel struct {
Name string
Bounds Rectangle
Character string
Title string // "PLAYER", "NPC", "GUIDE", …
Stats []CharStat
}
// ui.chat_log.go — Game.Messages() with per-kind colors, newest at the bottom
type ChatLog struct {
Name string
Bounds Rectangle
LineHeight int // default 10
Padding int // default 3
ShowBorder bool
}
Notes:
SpeechBubble text color: Character.SpeechColor, else Theme.SpeechDefaultText.DialogBox hover color is Theme.DialogChoiceHover; Once choices disappear after their first pick.HotspotDebug toggles at runtime with F1.TopBar skips empty fields, so it degrades to a two- or one-section layout.CharacterPanel and ChatLog reuse placeholder portraits and the theme's chat-log colors.ChatLog, set Game.MaxLogLines to about 64.Widgets that own a fixed area should not let a RadialVerbs menu pop up over them:
type clickBlocker interface {
BlocksClickAt(p Point) bool
}
Implemented by VerbBar, InventoryBar, DialogBox, TopBar, CharacterPanel, ChatLog, and RadialVerbs itself while visible. Implement it on your own widgets to mark them as solid input zones.
// ui.defaults.go
func RegisterDefaultUI(g *Game) // verb bar + inventory
func RegisterRadialVerbUI(g *Game) // verb coin + wider inventory
func RegisterRichUI(g *Game, playerName, npcName string) // top bar + panels + chat log + wheel
Pass "" for a character name to skip its CharacterPanel. All three register in conventional Z-order: debug overlay at the back, cursor at the front.
Anything implementing Widget works. Read ctx.Game, consume input via ctx.Game.Input, draw with Ebitengine.
type Minimap struct {
Name string
Bounds inkwell.Rectangle
}
func (m *Minimap) GetName() string { return m.Name }
func (m *Minimap) Tick(ctx *inkwell.UICtx) {}
func (m *Minimap) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
// render scene thumbnail, mark NPCs, …
}
g.UIManager.Register(&Minimap{Name: "minimap", Bounds: inkwell.Rect(220, 4, 96, 56)})
// ui.theme.go
type Theme struct {
Name string
PanelBG color.Color
StatusText color.Color
FlashText color.Color
VerbButtonBG color.Color
VerbButtonSelectedBG color.Color
VerbButtonText color.Color
InventorySlotBG color.Color
InventorySlotSelectedBG color.Color
SpeechBubbleBG color.Color
SpeechDefaultText color.Color
DialogBG color.Color
DialogBorder color.Color
DialogChoiceBG color.Color
DialogChoiceHover color.Color
DialogSpeaker color.Color
DialogText color.Color
EndCardBG color.Color
EndCardText color.Color
CursorColor color.Color
HotspotOutline color.Color
SceneBackdrop color.Color
TopBarBG color.Color
TopBarText color.Color
TopBarAccent color.Color
ChatLogBG color.Color
ChatLogPrompt color.Color
ChatLogResponse color.Color
ChatLogSystem color.Color
CharacterPanelBG color.Color
CharacterPanelBorder color.Color
CharacterPanelTitle color.Color
}
NewGame registers the presets and selects classic-scumm:
| Name | Mood |
|---|---|
classic-scumm |
SCUMM-era dark blue with warm amber accents (default). |
sierra-coin |
Darker purple and orange, made for the verb coin. |
paper-notebook |
Cream paper, ink, a little sepia. |
terminal-green |
Retro phosphor green on black. |
Your own theme is just another registration:
g.ThemeManager.Register(inkwell.Theme{
Name: "midnight-noir",
PanelBG: color.RGBA{6, 6, 12, 255},
StatusText: color.White,
// … the remaining color fields …
})
g.UseTheme("midnight-noir")
Widgets read the active theme every frame, so a runtime switch shows up on the next draw.
AssetManager.Register stores only the spec. The first image request:
Asset.Path,fnv32a(name) and mark the entry,*ebiten.Image.// internal
func (la *loadedAssets) image(am *AssetManager, name string) *ebiten.Image
func (la *loadedAssets) isPlaceholder(name string) bool
Widgets use isPlaceholder to decide between the stylised placeholder and the real sprite.
Image formats: PNG and JPEG. AssetAudio is decoded on demand by AudioPlayer. AssetFont shares the registry but is not loaded yet.
// asset.audio.go
func NewAudioPlayer() *AudioPlayer
func (a *AudioPlayer) PlayMusic(name string)
func (a *AudioPlayer) StopMusic()
func (a *AudioPlayer) PlaySound(name string)
Backed by Ebiten's audio. The shared context is created lazily at 48 kHz on the first play; an existing audio.CurrentContext (e.g. from a host process) is reused. The asset must have Kind: AssetAudio.
PlayMusic on the current track is a no-op, so calling it from OnEnter on every visit is safe.NewPlayerFromBytes for low latency. Finished players are pruned on the next PlaySound.Formats by extension: WAV, OGG Vorbis, MP3. Anything else fails to decode and the asset is flagged, so later plays no-op.
Every failure (missing file, unsupported codec, no audio device) is a debug log line — the game keeps running without sound.
// input.def.go
func (i *Input) Pos() (int, int)
func (i *Input) Point() Point
func (i *Input) LeftClicked() bool // just-pressed AND not consumed
func (i *Input) RightClicked() bool
func (i *Input) ConsumeLeft()
func (i *Input) ConsumeRight()
The engine polls once per frame and snapshots the cursor and both just-pressed states. Consume-on-use: one click is authoritative, whoever consumes first wins.
// core.dsl.go
func Run(g *Game) error // same as g.Run()
Run in order:
g.Audio.attach(g.AssetManager) — wire the audio player to whichever asset registry the game is carrying by now.g.Validate().RegisterDefaultUI(g) if UIManager is empty.State.NoteVisit, position actors, start music.Seq(scene.OnEnter, OnStart script, OnFinale script) as the first action.ebiten.SetWindowSize(Width*4, Height*4), ebiten.SetWindowTitle(g.Title), ebiten.RunGame(&engine{g}).engine.Update:
poll input
update transition
if fading out -> return
if scriptRunner != nil:
tick runner; tick characters; return
clear hoverLabel
for w in reversed(UIManager): w.Tick(uictx) # top-down input
handleSceneInput() # hotspots, right-click reset
tick characters
engine.Draw:
fill Theme.SceneBackdrop
draw scene background
for c in characters sorted by Y: drawCharacter(c)
for w in UIManager (registration order): w.Draw(screen, uictx)
draw transition overlay
handleSceneInput runs after every widget had its chance:
selectedVerb to "look".hotspot.OnUseWith[item], then item.OnUseWith[hotspot], else flash "Nem ehhez.".hotspot.handler(selectedVerb), then the verb's Default, else flash "Semmi említésre méltó.".LogAction line.Character movement: tickCharacters steps the head of the waypoint queue at Speed px/sec, pops it on arrival, and the character goes idle when the queue empties. The queue is built by Walk, which returns StatusRunning until the character stops. tickAnimation picks "walk" or "idle" each frame and blits the clip's source rect, or falls back to the whole sprite / placeholder.
Game.changeScene(name) starts a 0.25s fade to black. At the midpoint:
prevScene.OnLeave,currentScene,State.NoteVisit(name),SceneActor.At,Music,newScene.OnEnter.Then the fade back in. Input is frozen during the fade-out half. The scene from StartAt is set without a transition, so the intro script runs at once.
func (g *Game) Validate() error
Called by Run and usable from tests. It checks that:
StartAt names a registered scene,Background pointing at a registered asset,Scene.Music points at a registered asset,SceneActor.CharacterName is registered,Scene.Exit.To names a registered scene (empty is allowed — it means "back the way you came"),It returns the first error, wrapping an Err… sentinel for errors.Is. Duplicate names are caught earlier, by the panic in Register.
// state.save.go
func (g *Game) Save(slot int) error
func (g *Game) Load(slot int) error
One JSON file per slot, slot<N>.json under g.SaveDir (default saves/). Saved: current and previous scene, active verb, active theme, every character's position/target/moving flag, the whole State (flags, vars, visited, talked), and the inventory with its selected slot.
Static registrations are not saved — domain.Build() recreates them on every launch. References in the save are validated against the current managers before anything is touched, so a stale save errors out instead of corrupting the live *Game.
Not saved:
Runner and any active dialog — Load cancels both, so a save is effectively taken at an idle boundary;pos to target.Var values go through encoding/json as-is: numbers come back as float64, strings as strings. The format is versioned, so a file from a newer version errors instead of silently losing fields.
// core.errors.go
var (
ErrUnknownAsset = errors.New("inkwell: unknown asset")
ErrUnknownScene = errors.New("inkwell: unknown scene")
ErrUnknownItem = errors.New("inkwell: unknown item")
ErrUnknownCharacter = errors.New("inkwell: unknown character")
ErrUnknownDialogue = errors.New("inkwell: unknown dialogue")
ErrUnknownDialogueNode = errors.New("inkwell: unknown dialogue node")
ErrUnknownScript = errors.New("inkwell: unknown script")
ErrUnknownVerb = errors.New("inkwell: unknown verb")
ErrDuplicateName = errors.New("inkwell: duplicate name")
ErrNoStartScene = errors.New("inkwell: StartAt not set or unknown scene")
ErrSceneMissingBackground = errors.New("inkwell: scene has no background")
)
Register and MustGet panic instead of returning these: they signal construction-time bugs that should crash loudly.
The library is one package with no in-tree unit tests yet. The integration surface is covered by a headless smoke test — copy it into any project using the library. It opens no window and runs in milliseconds, so it is a cheap CI gate.
func TestBuildValidates(t *testing.T) {
g := domain.Build()
if err := g.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
}
Debug switches:
inkwell.DebugLog = true // script queue, audio, scene change -> stderr
&inkwell.HotspotDebug{Enabled: true} // start with the F1 overlay on
All sources sit at the repo root, named theme.identifier.go, so ls core.* or ls ui.* groups them.
inkwell/ # module git.teletypegames.org/engines/inkwell
├── core.doc.go # package docs
├── core.manager.go # Manager[T Named], Named, TypeLabel
├── core.game.go # Game aggregate, runtime state, helpers
├── core.engine.go # ebiten.Game adapter
├── core.dsl.go # Run()
├── core.errors.go # sentinel errors
│
├── util.geometry.go # Point, Rectangle, Polygon, Shape
├── util.timer.go # Timer helper
├── util.log.go # DebugLog + logf
│
├── asset.def.go # Asset, AssetKind
├── asset.manager.go # alias + lazy loader
├── asset.audio.go # AudioPlayer (WAV/OGG/MP3, looping music)
├── asset.text.go # drawText / wrapText
│
├── scene.def.go # Scene, SceneActor
├── scene.manager.go
├── scene.hotspot.go # Hotspot, CursorKind
├── scene.exit.go # Exit, ExitSide + edge-strip geometry
├── scene.trigger.go # Trigger + rising-edge sweep
├── scene.path.go # walkbox routing (BFS)
├── scene.transition.go # fade overlay (internal)
├── scene.camera.go # Camera (identity stub)
│
├── item.def.go # Item
├── item.manager.go
├── item.inventory.go # Inventory
│
├── actor.def.go # Character
├── actor.manager.go
├── actor.animation.go # AnimationClip + tickAnimation
│
├── dialog.def.go # Dialogue, DialogueNode, DialogueChoice
├── dialog.manager.go
│
├── action.def.go # Action, Runner, Ctx, Status + built-ins
├── action.condition.go # Condition + combinators
├── action.script.go # Script entity
├── action.manager.go
│
├── state.def.go # State (flags, vars, visited, talked)
├── state.save.go # Save/Load JSON
│
├── input.def.go # Input (consume-on-use)
│
├── ui.widget.go # Widget, UICtx, Size, Align
├── ui.manager.go # alias + ordered/reversed iterators
├── ui.theme.go # Theme + ThemeManager
├── ui.theme_presets.go # 4 presets
├── ui.defaults.go # RegisterDefaultUI / RadialVerbUI / RichUI
├── ui.verb.go # Verb + VerbManager
├── ui.verb_bar.go
├── ui.verb_radial.go
├── ui.inventory.go
├── ui.status.go
├── ui.speech.go
├── ui.dialog_box.go
├── ui.end_card.go
├── ui.cursor.go
├── ui.hotspot_debug.go
├── ui.panel.go
├── ui.top_bar.go
├── ui.character_panel.go # + CharStat
├── ui.chat_log.go
│
├── LICENSE.md # MIT
└── README.md
MIT licensed. Copyright © 2026 Teletype Games.