EN /HU | Login

Inkwell

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.

What you get

  • SCUMM-era skeleton — scenes with hotspots and walkboxes, verb-based interaction, inventory and item combinations, dialog trees, cutscenes.
  • One pattern — every named entity is a struct literal passed to Manager.Register.
  • Composable actionsSeq, Par, If, Wait, Say, Walk, … with lazy conditions over flags, vars and inventory.
  • Widget-tree HUD — verb bar, verb coin, inventory, dialog box, speech bubbles, top bar, character panels, chat log. Anything Ebitengine can draw is a valid widget.
  • Themes and placeholders — four color themes and deterministic placeholder art, so a game runs with zero assets on disk.
  • Save, load, validate — JSON save slots for mutable state, plus a startup validator for every name reference.

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.


Reference manual

Table of contents

  1. Quick start
  2. The Manager pattern
  3. The Game aggregate
  4. Geometry
  5. Entity reference
  6. The action system
  7. Conditions
  8. World state
  9. The widget system
  10. Themes
  11. Asset pipeline
  12. Audio
  13. Input
  14. The game loop
  15. Scene transitions
  16. Validation
  17. Save and load
  18. Errors
  19. Testing
  20. Project layout

Quick start

Needs Go 1.24+ (generic type aliases) and an OpenGL context.

BASH
go mod init my-game
go get git.teletypegames.org/engines/inkwell

If HTTPS to the private host fails, use SSH:

BASH
git config --global \
    url."ssh://git@git.teletypegames.org:2222/".insteadOf "https://git.teletypegames.org/"
export GOPRIVATE=git.teletypegames.org/*

A minimum game:

GO
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)
    }
}

The Manager pattern

Every name-addressable entity goes through one generic type:

GO
// 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:

GO
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.


The Game aggregate

GO
// 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:

GO
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.

GO
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:

GO
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.

GO
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).

GO
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

Geometry

GO
// 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.


Entity reference

Asset

GO
// 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

GO
// 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.

Exit

GO
// 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:

GO
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.

  • Placement. With no 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.
  • Phrasing in one place. Game.ExitLook and Game.ExitTake supply the look and take responses for every generated exit, so they are not repeated per exit.
  • The graph stays readable. The generated hotspot is named 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.

Hotspot

GO
// 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].

Trigger

GO
// 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

GO
// 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:

  1. hotspot.OnUseWith[item.Name]
  2. item.OnUseWith[hotspot.Name]
  3. otherwise flash "Nem ehhez." and deselect

Inventory

GO
// 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.

Character

GO
// 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
}
  • No sprite on disk → stylised placeholder: humanoid when W < H, quadruped otherwise. An RGBA SpeechColor also colors the placeholder body and the bubble text.
  • Clips are auto-selected by name: "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.
  • Frames are blitted with SubImage and scaled to W×H. Loop: false freezes on the last frame.

Dialogue

GO
// 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.

Script

GO
// 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").

Verb

GO
// 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.


The action system

GO
// 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.

Built-in actions

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.

Custom actions

For long-running state (animation, tween, poll) implement both interfaces:

GO
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:

GO
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
})

Conditions

GO
// 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.


World state

GO
// 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 widget system

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.

GO
// 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).

Z-order and input

  • 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.
  • After every widget ticked, the click is offered to handleSceneInput (hotspot interactions). A consumed click makes that a no-op.

Built-in widgets

One file each, ui.<name>.go. Zero-value fields fall back to sensible defaults.

GO
// 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.
  • With a small ChatLog, set Game.MaxLogLines to about 64.

The clickBlocker contract

Widgets that own a fixed area should not let a RadialVerbs menu pop up over them:

GO
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.

Registration helpers

GO
// 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.

Custom widgets

Anything implementing Widget works. Read ctx.Game, consume input via ctx.Game.Input, draw with Ebitengine.

GO
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)})

Themes

GO
// 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:

GO
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.


Asset pipeline

AssetManager.Register stores only the spec. The first image request:

  1. open Asset.Path,
  2. on a missing or unreadable file, generate a deterministic placeholder color from fnv32a(name) and mark the entry,
  3. cache the decoded *ebiten.Image.
GO
// 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.


Audio

GO
// 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.

  • Music streams from disk and loops forever. PlayMusic on the current track is a no-op, so calling it from OnEnter on every visit is safe.
  • Sound effects are decoded once, cached as raw PCM and replayed with 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

GO
// 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.


The game loop

GO
// core.dsl.go

func Run(g *Game) error    // same as g.Run()

Run in order:

  1. g.Audio.attach(g.AssetManager) — wire the audio player to whichever asset registry the game is carrying by now.
  2. g.Validate().
  3. RegisterDefaultUI(g) if UIManager is empty.
  4. Place the start scene with no transition, bump State.NoteVisit, position actors, start music.
  5. Queue Seq(scene.OnEnter, OnStart script, OnFinale script) as the first action.
  6. ebiten.SetWindowSize(Width*4, Height*4), ebiten.SetWindowTitle(g.Title), ebiten.RunGame(&engine{g}).
TEXT
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:

  1. Set the hover label from the hotspot under the cursor.
  2. Right-click: deselect the held item, or reset selectedVerb to "look".
  3. Left-click with an item: hotspot.OnUseWith[item], then item.OnUseWith[hotspot], else flash "Nem ehhez.".
  4. Left-click without: hotspot.handler(selectedVerb), then the verb's Default, else flash "Semmi említésre méltó.".
  5. Every successful click pushes a 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.


Scene transitions

Game.changeScene(name) starts a 0.25s fade to black. At the midpoint:

  1. queue prevScene.OnLeave,
  2. set the new currentScene,
  3. State.NoteVisit(name),
  4. place actors at their SceneActor.At,
  5. play the new Music,
  6. queue 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.


Validation

GO
func (g *Game) Validate() error

Called by Run and usable from tests. It checks that:

  • StartAt names a registered scene,
  • every scene has a non-empty Background pointing at a registered asset,
  • a non-empty Scene.Music points at a registered asset,
  • every SceneActor.CharacterName is registered,
  • every Scene.Exit.To names a registered scene (empty is allowed — it means "back the way you came"),
  • an active theme is selected and registered.

It returns the first error, wrapping an Err… sentinel for errors.Is. Duplicate names are caught earlier, by the panic in Register.


Save and load

GO
// 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:

  • the in-flight Runner and any active dialog — Load cancels both, so a save is effectively taken at an idle boundary;
  • animation playback state — the next idle frame re-derives the clip;
  • walkbox path queues — a still-moving character gets a one-step straight-line path from 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.


Errors

GO
// 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.


Testing

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.

GO
func TestBuildValidates(t *testing.T) {
    g := domain.Build()
    if err := g.Validate(); err != nil {
        t.Fatalf("validate: %v", err)
    }
}

Debug switches:

GO
inkwell.DebugLog = true                 // script queue, audio, scene change -> stderr
&inkwell.HotspotDebug{Enabled: true}    // start with the F1 overlay on

Project layout

All sources sit at the repo root, named theme.identifier.go, so ls core.* or ls ui.* groups them.

TXT
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.