TITLE=Inkwell ROUTE=/engines/inkwell LOCALE=en MODIFIED=1792417593 URL=https://wiki.teletypegames.org/engines/inkwell L=1 https://ebitengine.org/ L=2 ssh://git@git.teletypegames.org:2222/demos/inkwell-demo q│ Inkwell is a point-and-click adventure framework for Go, built on q│ 1Ebitengine. You register plain struct literals into managers; the library q│ does input, rendering, dialog trees, cutscenes, HUD, themes and lazy asset q│ loading. Your code only declares content. 1What you get 1════════════ • bSCUMM-era skeleton0 — scenes with hotspots and walkboxes, verb-based interaction, inventory and item combinations, dialog trees, cutscenes. • bOne pattern0 — every named entity is a struct literal passed to cManager.Register0. • bComposable actions0 — cSeq0, cPar0, cIf0, cWait0, cSay0, cWalk0, … with lazy conditions over flags, vars and inventory. • bWidget-tree HUD0 — verb bar, verb coin, inventory, dialog box, speech bubbles, top bar, character panels, chat log. Anything Ebitengine can draw is a valid widget. • bThemes and placeholders0 — four color themes and deterministic placeholder art, so a game runs with zero assets on disk. • bSave, load, validate0 — JSON save slots for mutable state, plus a startup validator for every name reference. bModule path:0 cgit.teletypegames.org/engines/inkwell The demo game "Morning Coffee" lives in 2cinkwell-demo0 and consumes the library as a normal Go module. r────────────────────────────────────────────────────────────────────────────── 1Reference manual 1════════════════ 2Table of contents 2───────────────── 1. Quick start 2. The Manager pattern 3. The Game aggregate • Extensions, and the Steam layer 4. Text and languages 5. Geometry 6. Entity reference • Achievement 7. The action system 8. Conditions 9. World state 10. The widget system • The menu system • The debug menu 11. Themes 12. Asset pipeline 13. Audio 14. Input 15. The game loop 16. Scene transitions 17. Validation 18. Save and load 19. Errors 20. Testing 21. Project layout r────────────────────────────────────────────────────────────────────────────── 2Quick start 2─────────── Needs Go 1.24+ (generic type aliases) and an OpenGL context. p go mod init my-game p go get git.teletypegames.org/engines/inkwell If HTTPS to the private host fails, use SSH: p git config --global \ p url."ssh://git@git.teletypegames.org:2222/".insteadOf "https://git.telet» p ypegames.org/" p export GOPRIVATE=git.teletypegames.org/* A minimum game: p package main p p import ( p "log" p "git.teletypegames.org/engines/inkwell" p ) p p func main() { p player := &inkwell.Character{Name: "player", W: 28, H: 62} p room := &inkwell.Scene{ p Name: "room", p Layers: []inkwell.SceneLayer{{Asset: &inkwell.Asset{Name: "bg/ro» p om", Path: "assets/bg/room.png"}, Z: -1}}, p Actors: []inkwell.SceneActor{{Character: player, At: inkwell.Poi» p nt{X: 160, Y: 140}}}, p Hotspots: []inkwell.Hotspot{{ p Name: "door", p Area: inkwell.Rect(260, 50, 40, 90), p Label: inkwell.Text{{Lang: "en", Text: "door"}}, p OnLook: inkwell.Say(player, inkwell.Text{ p {Lang: "en", Text: "A wooden door."}, p }), p }}, p } p world := &inkwell.Stage{Name: "game", World: true} p p g := inkwell.New(inkwell.Game{ p Title: "Sample", p Width: 320, p Height: 200, p StartStage: world, p StartScene: room, p Player: player, p }) p g.CharacterManager.Register(player) p g.SceneManager.Register(room) p g.StageManager.Register(world) p p inkwell.RegisterDefaultUI(g) // optional; auto-called by Run if no widge» p ts registered p if err := inkwell.Run(g); err != nil { p log.Fatal(err) p } p } r────────────────────────────────────────────────────────────────────────────── 2The Manager pattern 2─────────────────── Every name-addressable entity goes through one generic type: p // core.manager.go p p type Named interface { p GetName() string p } p p type Manager[T Named] struct { /* ... */ } p p type ItemManager = Manager[*Item] p type SceneManager = Manager[*Scene] p type CharacterManager = Manager[*Character] p type SpeakerManager = Manager[*Speaker] p type DialogManager = Manager[*Dialog] p type ScriptManager = Manager[*Script] p type AssetManager = Manager[*Asset] p type VerbManager = Manager[*Verb] p type WidgetManager = Manager[Widget] p type ThemeManager = Manager[*Theme] t┌─────────────────────┬──────────────────────────────────────────────────────┐ t│h Method t│h Behaviour t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cRegister(v T) T0 t│0 Adds cv0 and hands it back. Panics on empty or t│ t│0 t│0 duplicate cName0. t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cSet(v T)0 t│0 Replaces an entry in place, keeping its position; t│ t│0 t│0 registers it when the name is new. t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cGet(name) (T, bool)0 t│0 Lookup; cfalse0 if absent. t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cMustGet(name) T0 t│0 Same, but panics on a missing name. t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cHas(name) bool0 t│0 True if registered. t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cLen() int0 t│0 Number of entries. t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cNames() []string0 t│0 Names in binsertion order0 (widget Z-order uses this). t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cAll() []T0 t│0 Every entry in binsertion order0. t│ t├─────────────────────┼──────────────────────────────────────────────────────┤ t│0 cEach(fn func(T))0 t│0 Iterate in insertion order. t│ t└─────────────────────┴──────────────────────────────────────────────────────┘ cRegister0 returns what it registered so that a registration can be an bexpression0 rather than a statement, which is what lets an entity file be a declaration instead of a procedure — and so that the variable it binds is the handle. Every manager holds bpointers0, cManager[*Scene]0 rather than cManager[Scene]0, so the variable and the entry the engine looks up are one instance, and every field that refers to another entity is a pointer to it: cStage.Background *Asset0, cExit.To *Scene0, cSceneActor.Character *Character0, cSay(speaker Talker, …)0. A reference is an identifier, checked by the compiler and followed by an editor; cName0 is left with the one job only a string can do, the key a save file, a cState0 var and a command-line flag use to find the entity again. p // scene.alley.go — the whole file p package scene p p var Alley = SceneManager.Register(&Scene{ p Name: "alley", p … p }) That is not merely shorter than wrapping the call in an cinit()0. A package-level variable is initialised in bdependency order0, so the manager is built before any file that writes into it, whichever one the compiler reads first; the cinit()0 form rests instead on the separate rule that every package variable is ready before any cinit()0 runs. Registration order is unchanged either way. The result is safe to ignore, so the plain statement form still compiles, and a file that has to ibuild0 something before registering it keeps its cinit()0 and should. Every registerable struct implements cGetName()0. An optional cTypeLabel()0 makes panic messages clearer: p func (i Item) GetName() string { return i.Name } p 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 — cSet0 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. r────────────────────────────────────────────────────────────────────────────── 2The Game aggregate 2────────────────── p // core.game.go p p type Game struct { p Title string p Width, Height int p p ItemManager *ItemManager p SceneManager *SceneManager p CharacterManager *CharacterManager p SpeakerManager *SpeakerManager p DialogManager *DialogManager p ScriptManager *ScriptManager p AssetManager *AssetManager p VerbManager *VerbManager p WidgetManager *WidgetManager p ThemeManager *ThemeManager p StageManager *StageManager p p State *State p Inventory *Inventory p Audio *AudioPlayer p Input *Input p p SceneRect Rectangle // the part of the window the picture occup» p ies p ExitLook func(Exit) Action // default look response for generated exit» p s p ExitTake func(Exit) Action // default take response for generated exit» p s p p UseWithFail func(item *Item, h *Hotspot) Action // unauthored use-with» p pair p Player *Character // actor a scene with » p none gets; who WalkOnClick moves p WalkOnClick bool // a click on empty fl» p oor walks Player (default off) p Walkboxes []Polygon // floor a scene with » p none walks p WindowScale int // window = resolution» p × this (0 = 4) p p StartStage *Stage // the screen the game opens on p StartScene *Scene // the scene a session opens in p StartScript *Script // queued after that scene's OnEnter, once p FinaleScript *Script // closing script, queued straight after StartScr» p ipt p StartTheme *Theme // theme selected at boot (nil -> classic-scumm) p Flags []string // State flags set before the first frame p SystemLang string // the language the machine or platform reports; » p after the stored one, before Fallback p p MaxLogLines int p p App string // the game's name to the machine: its config directory, » p its localStorage prefix p Store Store // nil takes the platform's default for App p p AchievementManager *AchievementManager // what the player can be recog» p nised for — see Achievement p Extensions []Extension // platform layers: started, ti» p cked, stopped — see below p } cNew(spec Game) *Game0 builds a game from a declared one. Everything a game iis rather than idoes0 is a field, so the whole of it is one literal at the call site instead of a page of assignments the reader has to hold in their head: p game := inkwell.New(inkwell.Game{ p Title: "Underwater Sun Rising", p Width: 640, p Height: 380, p WindowScale: 2, p p StartScene: "alley", p StartScript: "opening", p StartTheme: "realworld-93", p p Player: "paul", p Walkboxes: scene.Floor, p UseWithFail: script.UseWithFail, p p SceneManager: scene.SceneManager, p ScriptManager: script.ScriptManager, p WidgetManager: widget.WidgetManager, p ThemeManager: theme.ThemeManager, p }) What the spec leaves out, cNew0 fills in: an empty manager for every category the caller did not hand over, the runtime halves (cState0, cInventory0, cAudio0, cInput0), the four SCUMM verbs (clook0, cuse0, ctalk0, ctake0) when no verbs were registered, and the four preset themes. Widgets are bnot0 auto-registered — call cRegisterDefaultUI(g)0, or let cRun0 do it when cWidgetManager0 is empty. A manager handed over here is used bin place, not copied0, so the engine and the domain share one registry per category instead of two to keep in step — and handing one over no longer costs anything. cThemeManager0 used to arrive holding the presets, so replacing it threw them away; cNew0 now iadds0 the presets to whichever cThemeManager0 it ends up with, leaving alone any name the domain has already taken. cVerbManager0 works the same way: hand over a full one and the SCUMM four are not installed over it. cNewGame(title, w, h)0 is cNew0 for a game that would rather be built in steps — it declares nothing but the window, and the caller assigns the rest: p g := inkwell.NewGame("Underwater Sun Rising", 640, 380) p g.SceneManager = mySceneManager p g.AssetManager = myAssetManager Do it before cRun0 — that is where the engine wires up the parts that hold a registry directly. p func (g *Game) StartAt(scene *Scene) *Game // entry scene p func (g *Game) OnStart(script *Script) *Game // script run after the scene» p 's OnEnter p func (g *Game) OnFinale(script *Script) *Game // closing script, queued afte» p r OnStart p func (g *Game) Validate() error // cross-check what the types » p cannot p func (g *Game) Run() error // same as inkwell.Run(g) p p func (g *Game) CurrentScene() string // where the player is p func (g *Game) PreviousScene() string // where they came from; Back(» p ) goes here p func (g *Game) SceneArea() Rectangle // SceneRect, or the whole win» p dow p func (g *Game) SceneHotspots(name string) []Hotspot p p func (g *Game) Theme() *Theme p func (g *Game) UseTheme(theme *Theme) // runtime switch, next frame » p uses it cStartScript0 and cFinaleScript0 name registered scripts rather than taking an action, so the opening a game plays is content like any other — a cScript0 in the cScriptManager0, editable without touching the wiring. Both are pointers, so a script that does not exist cannot be named. cFinaleScript0 is queued directly after cStartScript0, which is what a "boot straight into the ending" debug flag wants. cStartAt0, cOnStart0 and cOnFinale0 are setters for those three fields, for a caller building a game in steps rather than declaring one; they return c*Game0, so they chain. A game that hands cNew0 a literal names the fields instead and needs none of them: p game := inkwell.New(inkwell.Game{ p … p StartScene: start, p StartScript: ScriptOpening, p FinaleScript: finaleScript, // "" for an ordinary run p }) cSceneHotspots0 is a scene's own hotspots followed by one per cExit0, cached per scene; authored hotspots come first, so a painted thing beats the edge strip an exit sits on. cCurrentScene0 and cPreviousScene0 both survive a save. UI runtime state lives on cGame0: actions and the engine write it, widgets read it. p func (g *Game) SelectedVerb() string p func (g *Game) SetSelectedVerb(s string) p p func (g *Game) HoverLabel() string // raw target label p func (g *Game) SetHoverLabel(s string) p func (g *Game) HoverHint() string // composed "verb + target" p p func (g *Game) SetSpeech(speaker, text string) p func (g *Game) ClearSpeech() p p func (g *Game) FlashLine(text Text) // ~2s status banner p p func (g *Game) InvokeVerb(h *Hotspot, verb string) // verb + target -> que» p ued action p p func (g *Game) CurrentStage() *Stage // the screen showing p func (g *Game) PreviousStage() *Stage // the one before it p func (g *Game) OnWorldStage() bool // is the game itself showing? p func (g *Game) Started() bool // does a session exist? p func (g *Game) GoToStage(stage *Stage) // show another screen, next f» p rame p func (g *Game) Restart() // begin a new session, next f» p rame p func (g *Game) LoadLater(slot int) // restore a session, next fra» p me p func (g *Game) Quit() // close the window p func (g *Game) CharacterInScene(character *Character) bool // CharacterPan» p el auto-hide p func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color» p ) p func (g *Game) Do(a Action) // queue an action; runs in or» p der p p const GlyphW, GlyphH = 6, 16 // the built-in font's cell p func TextWidth(s string) int p func WrapText(s string, maxPx int) []string p func ClipText(s string, maxPx int) string cDrawText0 renders in the colour it is handed, glyph by glyph out of the engine's own atlas: each cell is a white glyph over a black drop shadow, so scaling the blit by the colour tints the glyph and leaves the shadow black, and every cTheme0 text colour is live. These four take a resolved cstring0, not a cText0 — they are the sink, and a cText0 reaches them through cString()0. cTextWidth0, cWrapText0 and cClipText0 are what the built-in widgets lay out with, and a HUD's vertical measurements should derive from cGlyphH0. cDo0 queues an action. Queued actions run one at a time and in order — the engine starts the next one on the first frame the previous is done, so a widget, a trigger and a hotspot click can all hand work in without any of them being dropped. The engine's own hotspot and cOnEnter0 actions use the same queue. The chat log is a ring buffer; bound it with cGame.MaxLogLines0 (0 = unbounded). p type LogKind int p const ( p LogAction // "> look at door" — player commands p LogResponse // in-world replies, Say output p LogSystem // meta p ) p p type LogMessage struct { p Speaker string p Text string p Kind LogKind p } p p func (g *Game) LogAction(text string) p func (g *Game) LogResponse(speaker, text string) p func (g *Game) LogSystem(text string) p func (g *Game) Messages() []LogMessage r────────────────────────────────────────────────────────────────────────────── 3Extensions, and the Steam layer p // inkwell/extension.def.go p p type Extension interface { p Start(game *Game) error // top of Run, before the settings are read p Tick(ctx *Ctx) // first thing in every Update, on every Stage p Stop() // once the ebiten loop has returned p } p p type Unlocker interface { // optional: an Extension that records achieve» p ments p Unlock(achievement *Achievement) p } An cExtension0 is machinery that lives beside the game for the whole run and is not a widget: a platform layer, which has to be up before the window opens, pumped every frame whether a script holds the frame or not, and shut down when the loop returns. A widget stands still while a script runs and has no hook at either end, which is why this is its own contract. cStart0 runs before cloadSettings0, so an extension may hand the game a cStore0 of its own — the settings are then read from it — and may set cGame.SystemLang0, the language the machine or the platform reports. cRun0 resolves the language in this order: whatever was set before cRun0 (a c-lang0 flag), the stored setting, cSystemLang0, cFallback0. An error from cStart0 ends cRun0. cStop0 runs in reverse order, through a cdefer0, so it runs whichever way the loop returned. An extension that implements cUnlocker0 is handed every cAchievement0 the world earns — see Achievement. It is asked for by type assertion, the way an action asks its host, so an extension with no notion of achievements implements nothing extra. csteam/0 is the one extension the engine ships, in its own package, cgit.teletypegames.org/engines/inkwell/steam0, which the root package does not import — a game that never names it links none of it, and none of cgithub.com/bogdannbv/go-steamworks0, the purego binding underneath. It is one literal in the cGame0: p Extensions: []inkwell.Extension{ p &steam.Steam{ p AppID: 480, p Languages: map[string]string{ p "english": "en", p "hungarian": "hu", p }, p Cloud: []string{inkwell.SlotsKey}, p }, p }, cStart0 loads the redistributable, relaunches the binary through Steam when it was started outside it (cRestartAppIfNecessary0 with cAppID0), initialises the API, and then hands the game what it learned: the client's language through cLanguages0, which maps Steam's names (c"english"0, c"hungarian"0) onto the game's codes, and for the cStore0 keys named in cCloud0 a cStore0 backed by Steam Cloud, one file per document. Every other key stays with the platform's default store, and the cloud store degrades toward the disk: a cloud read that finds nothing falls back to the local document — which is how a save made before the game had Steam reaches the load screen and gets uploaded on the next write — and a failed cloud write is written locally instead and logged. cTick0 is cRunCallbacks0, cStop0 is cShutdown0, and cUnlock0 is cSetAchievement0 plus cStoreStats0. When the library cannot be loaded or the API does not initialise, the extension logs why and stands down: the game runs without Steam rather than not at all. cRunning()0 says which happened. At runtime the Steam client must be running and the redistributable — csteam_api64.dll0, clibsteam_api.so0, clibsteam_api.dylib0 — must be in the working directory, which is where Steam launches a game from; the cSTEAMWORKS_LIB_PATH environment variable names another location, and cSTEAM_APPID0 makes the binding write the csteam_appid.txt0 a run outside Steam needs. The binding is 64-bit only, Windows, Linux and macOS. r────────────────────────────────────────────────────────────────────────────── 2Text and languages 2────────────────── Every string the player reads is a cText0 — the same string in as many languages as somebody has written it in. p // i18n.text.go p p type Translation struct { p Lang string p Text string p Voice *Voice // the recording of this line in this language, if any p } p p type Text []Translation p p var Lang string // the language the game speaks p var Fallback string // used when Lang has no translation p p func (t Text) String() string p func (t Text) In(lang string) string p func (t Text) Empty() bool p func (t Text) Map(fn func(string) string) Text p func Format(format Text, args ...any) Text The wording stays where the thing it names is: a hotspot's label is in the hotspot, a line of dialog is in the scene that speaks it. Nothing is lifted out into a catalogue keyed by identifier — an identifier is one more name to invent and one more thing to keep in step, and a translator reading a scene file can see what the line is answering. One translation per line, stacked, so the languages read as a table: p Title: inkwell.Text{ p {Lang: "en", Text: "ALLEY — BEHIND NOODLE'S HOUSE"}, p {Lang: "hu", Text: "SIKÁTOR — NOODLE HÁZA MÖGÖTT"}, p }, p p inkwell.Say(paul, inkwell.Text{ p {Lang: "en", Text: "Locked. And I'm not guessing four digits."}, p {Lang: "hu", Text: "Zárva. És nem fogok négy számjegyet találgatni."}, p }) Adding a language is adding a line, and it touches nothing around it. The zero value says nothing in any language, so a field nobody has translated behaves exactly like the c""0 it used to be, and a cText0 with one language behaves like a plain string. Inside a c[]Text0 or a cmap[string]Text0 the element type is implied, which is what cgofmt -s0 leaves behind — c"look": {{Lang: "en", …}}0. cString()0 reads the package-level cLang0, falls back to cFallback0, and falls back again to whichever translation was written first, so an untranslated line still reaches the player rather than vanishing. Set both once, before cRun0: p inkwell.Fallback = "en" p inkwell.Lang = *langFlag They are package variables rather than fields on cGame0 because a cText0 resolves itself wherever it is read: a widget's cDraw0, the cSay0 runner, a hover label, the hotspot an cExit0 generates. Threading a c*Game0 through all of those would put a parameter on every one of them for a value there is only ever one of; cAssetFS0 is a package variable for the same reason. Resolution happens at the moment of display, never at construction, so content built in an cinit()0 is unaffected by when the language is set. 3Composing A sentence built around another cText0 has to come out in one language throughout. cFormat0 resolves the frame and every cText0 argument in the same language; anything that is not a cText0 is printed as it stands. p var exitLook = inkwell.Text{ p {Lang: "en", Text: "That way: %s."}, p {Lang: "hu", Text: "Arra van: %s."}, p } p p g.ExitLook = func(e inkwell.Exit) inkwell.Action { p return inkwell.Say(paul, inkwell.Format(exitLook, e.Label)) p } This is not a join, and that is the point: English puts the preposition between the verb and its object, Hungarian puts it after them. A frame per language is the only thing that survives contact with a second one. cMap0 is the other composer — it rewrites every translation and keeps the languages, which is what a derived label wants: p pin := scene.Title.Map(func(s string) string { p name, _, _ := strings.Cut(s, " — ") p return name p }) 3What the engine says itself The handful of sentences inkwell puts on screen without a game having authored them live in ci18n.strings.go0, in English and Hungarian: cMsgNothing0 (a verb nothing is bound to), cMsgNotThat0 (an unpaired use-with when cUseWithFail0 is nil), cMsgNeedItem0 (cRequireItem0 on an item the player hasn't got), cHintVerb0 and cHintVerbOn0 (the status line), cScoreLabel0 (the cTopBar0 score prefix). They are variables, so a game that speaks a language the engine has not been taught can teach it one: p inkwell.MsgNothing = append(inkwell.MsgNothing, inkwell.Translation{ p Lang: "de", p Text: "Nichts von Bedeutung.", p }) The four preset verbs carry both languages too, and a game that wants a third replaces them through cVerbManager.Set0. 3Language-stable state Two things that used to be keyed by wording are not any more, because wording is no longer a stable identity. A cOnce0 dialog choice is recorded against its bposition0 in its node, not its text — a choice the player has used up stays used up when the game is played in another language, and a translator can reword a line without reopening somebody's save. cTopBar.NoteVar0 reads a cState0 var, and a cCondition0 compares against that value, so the value itself has to be identical in every language. cTopBar.Notes0 maps it to what the player reads: p &inkwell.TopBar{ p NoteVar: "note", p Notes: map[string]inkwell.Text{ p "paused": { p {Lang: "en", Text: "paused"}, p {Lang: "hu", Text: "szünet"}, p }, p }, p } 3The font cText0 is only half of it: a game that speaks a second language needs a font that can draw it. The engine used to render through cebitenutil.DebugPrintAt0, whose atlas is 256 cells — U+0000 to U+00FF, Latin-1 and no further. That is enough for English and stops one character short of Hungarian: cő0 and cű0 are in Latin Extended-A, and so is most of what Polish, Czech, Croatian and Turkish need. Anything past U+00FF drew as a blank gap, the em dashes and ellipses the engine already emitted included. inkwell now ships its own atlas, cfont.png0, baked by cinternal/fontgen0 out of cbitmapfont/v40: t┌───────────────┬────────────────────────────────────────────────┐ t│h Range t│h What it is t│ t├───────────────┼────────────────────────────────────────────────┤ t│0 U+0020–U+007E t│0 ASCII t│ t│0 U+00A0–U+00FF t│0 Latin-1 supplement t│ t│0 U+0100–U+017F t│0 Latin Extended-A t│ t│0 U+2010–U+2027 t│0 dashes, curly quotes, dagger, bullet, ellipsis t│ t│0 U+20AC t│0 € t│ t│0 U+2190–U+2193 t│0 arrows t│ t└───────────────┴────────────────────────────────────────────────┘ 348 glyphs, under 8 KB — the Latin part of a font whose full face is 2.5 MB of embedded CJK, which is the whole of what a European point & click ever puts on screen. A rune outside the coverage draws a hollow box, so a gap is visible rather than silent. cGlyphW0 and cGlyphH0 are unchanged at 6×16, every cell carries the same baked drop shadow the debug font had, and the run still starts one pixel right of cx — so every layout measured against the old renderer still measures the same. To widen the coverage, edit cranges0 in cinternal/fontgen/main.go0 and run it; it regenerates both cfont.png0 and ci18n.glyphs.go0, and refuses any rune the fixed 6-pixel cell cannot hold. p go -C internal/fontgen run . cinternal/fontgen0 is its own module, so the engine's own dependency list stays at one entry. 3A ladder of lines p // i18n.ladder.go p p type Ladder struct { p Rungs []Text // one line per attempt, in order p Last Text // stands once the rungs run out; empty picks at random p } p p func (l Ladder) At(n int) Text A cLadder0 is what somebody says the n-th time the player does the same fruitless thing. Answering a repeated failure with one line is the surest way to sound like a machine; a cLadder0 answers the first attempt with the first rung and keeps a character's patience visibly running out. cAt0 counts the first attempt as 1. Past the last rung a cLadder0 picks one at random, which keeps a stock refusal from turning into a stuck record — unless it names a cLast0, which stands from then on, because a character who has given up has given up. The counter is the game's: cState.NoteTalked0 / cState.Talked0 already count how many times a thing has been worried at, keyed however the game wants — per item, per hotspot, per pair. cGame.UseWithFail0 is the usual caller. r────────────────────────────────────────────────────────────────────────────── 2Geometry 2──────── p // util.geometry.go p p type Point struct{ X, Y float64 } p p func (p Point) Add(q Point) Point p func (p Point) Dist(q Point) float64 p p type Shape interface { p Contains(p Point) bool p Bounds() Rectangle p } p p type Rectangle struct{ X, Y, W, H float64 } p p func Rect(x, y, w, h float64) Rectangle p func (r Rectangle) Contains(p Point) bool p func (r Rectangle) Bounds() Rectangle p p type Polygon struct{ Points []Point } p p func Poly(pts ...Point) Polygon p func PolyRect(x, y, w, h float64) Polygon // the rectangle as four corne» p rs p func (g Polygon) Contains(pt Point) bool // ray-casting p func (g Polygon) Bounds() Rectangle // axis-aligned bbox Both shapes work as a cHotspot.Area0. Hotspots are hit-tested with cShape.Contains0 every frame. cPolyRect0 exists because a walkbox is a cPolygon0 so that a floor can be any shape — but most floors start as a box, and spelling one out corner by corner says nothing the numbers had not already said. r────────────────────────────────────────────────────────────────────────────── 2Entity reference 2──────────────── 3Asset p // asset.def.go p p type Asset struct { p Name string // logical id, "bg/kitchen" p Path string // "assets/bg/kitchen.png" p } p p // audio.def.go — sound is not an Asset p p type Audible interface{ Source() string } // Voice, Music and Sound p p type Voice struct{ Path string } // a recorded line, on a Trans» p lation p type Music struct{ Name, Path string; Loop bool } // a track a Scene or Stag» p e plays p type Sound struct{ Name, Path string } // a noise, played once by Pla» p ySound p p kitchen := g.AssetManager.Register(&inkwell.Asset{ p Name: "bg/kitchen", Path: "assets/bg/kitchen.png", p }) cRegister0 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. An cAsset0 is a picture; what is heard is a cVoice0, a cMusic0 or a cSound0 — see Audio. 3Scene p // scene.def.go p p type Scene struct { p Name string p Title Text // human-readable, read by TopBar p Layers []SceneLayer // the picture in planes, each at a depth Z; e» p mpty leaves the theme's backdrop p Music *Music // starts on enter; nil changes nothing p Hotspots []Hotspot p Exits []Exit // connections to other scenes p Walkboxes []Polygon p Triggers []Trigger p Actors []SceneActor p OnEnter Action p OnLeave Action p Derived func(g *Game, self *Scene) []Exit p } p p func (s *Scene) Leads(exits ...Exit) *Scene // append exits after declarati» p on p p type SceneActor struct { p Character *Character p At Point p } p p type SceneLayer struct { p Asset *Asset p Z int // the cast is at 0: negative is behind every character, » p positive in front p } • cOnEnter0 is queued when the scene becomes current (after the fade-in), cOnLeave0 on the way out. • cActors0 says which registered characters stand in the scene, and where their feet start. • cWalkboxes0 constrain movement. With walkboxes, cWalk0 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. • cLayers0 is the picture, in planes. Every layer is a full-screen image with transparency where the planes behind it show through, at a depth cZ0, and the cast stands at 0: a negative cZ0 is behind every character, a positive cZ0 in front of every character, and the further from 0 the further from the cast. The background is the layer at -1, a foreground — a fence along the bottom edge, a counter — the layer at 1. Two layers at one cZ0 keep the order they were written in. • A layer's cAsset0 need not be registered anywhere: a scene may nest the literal, or a game may derive one per scene after registration, the way a game whose backgrounds are named after their scenes does. An empty cLayers leaves the theme's backdrop showing. A missing file is the usual named placeholder for the lowest layer, and nothing at all for a layer above it — a missing piece of transparency is not a plate to paint over the scene. Every plane is the whole canvas, so the painter exports them from the same file as the background, one PNG per layer, and there is no offset to keep in step. • bNaming another scene.0 cExit.To0 and cGoTo0 hold the c*Scene0 itself, and two package-level variables may not refer to each other in their initialisers — which two scenes that lead to each other would. So a scene file declares the scene first and its exits second, with cLeads0, a method that appends to cExits and hands the scene back: cvar _ = Alley.Leads(inkwell.Exit{To: Street, Side: cinkwell.ExitLeft})0. The second declaration depends on both scenes and neither depends on it, so the graph may be as tangled as the city is. • cExits0 are the scene's connections to other scenes, declared as data. The engine turns each one into a hotspot — see below. • cDerived0 hands back exits worked out from the rest of the game rather than written into this scene — the pins on a map screen, which are every location that says it leads there. cRun0 asks each scene once, before cValidate0, and appends what comes back to cExits0, so validation, hotspot expansion and the debug menu all see one kind of exit, and the scene file stays a literal instead of being patched from the wiring after registration. 3Exit p // scene.exit.go p p type Exit struct { p To *Scene // where it leads; nil means "back the way you came" p Label Text // what the status line calls it p Side ExitSide // where it sits when Area is nil p Area Shape // overrides the edge strip Side would give it p Needs string // flag that has to be set before it opens p Blocked Action // what happens while it is not p OnLook Action // overrides Game.ExitLook for this exit p OnTake Action // overrides Game.ExitTake for this exit p } p p type ExitSide int p const ( p ExitLeft ExitSide = iota // off the left edge p ExitRight // off the right edge p ExitBack // into the depth of the picture p ExitNear // out towards the camera p ) p p func ExitName(to *Scene) string A connection belongs to the scene it leads out of, so it is written in that scene's own file, right after the scene, with cLeads0 — after rather than inside the literal because the scene it leads to may lead back: p var Alley = SceneManager.Register(&Scene{Name: "alley", …}) p p var _ = Alley.Leads( p Exit{To: NoodleHouse, Label: "back out to the street", Side: ExitLeft}, p Exit{To: nil, Label: "the fire escape", Side: ExitBack, p Needs: "has_ladder", Blocked: Say(paul, "Can't reach it.")}, p ) The engine expands each exit into a cHotspot0: cCursor: CursorExit0, the exit's cLabel0, and cOnUse0 bound to cGoTo(To)0 — or cBack()0 when cTo0 is nil. With cNeeds0 set, the whole travel is wrapped in cIf(Flag(Needs), travel, Blocked)0, so the exit is visible either way: a locked door still says it is a door. • bPlacement.0 With no cArea0, an exit is a strip along one edge of the picture, sized as a fraction of cGame.SceneArea()0 — 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 cGame.SceneRect0, so the strips land inside the painting instead of under the HUD. • bPhrasing in one place.0 cGame.ExitLook0 and cGame.ExitTake0 supply the look and take responses for every generated exit, so they are not repeated per exit. • bThe graph stays readable.0 The generated hotspot is named cExitName(To)0 — c"exit:noodle_house"0, or c"exit:back"0 — so the location graph can be read straight back out of the registered scenes, and an exit can only name a scene that exists, because it holds it. 3Hotspot p // scene.hotspot.go p p type Hotspot struct { p Name string p Area Shape // Rectangle or Polygon p Label Text // hover hint p Cursor CursorKind p p OnLook Action p OnUse Action p OnTalk Action p OnTake Action p OnGive Action p p OnUseWith map[*Item]Action // item -> action p OnUseWithAny Action // one answer for any item OnUseWith d» p oes not name p OnVerb map[string]Action // custom verb Name -> action p } p p type CursorKind int p const ( p CursorDefault CursorKind = iota p CursorLook p CursorUse p CursorTalk p CursorTake p CursorExit p ) Hotspots belong to a scene, not to a manager. A click resolves via chotspot.handler(verbName)0: the four built-in verbs map to the cOn*0 fields, everything else falls back to cOnVerb[verbName]0. cOnUseWithAny0 answers for an item cOnUseWith0 does not name. A slot, a lock or a person usually has one reply for whatever you put on it, and enumerating the item catalogue to say it is a list that has to be kept in step with one. It stands between the authored pairs and cGame.UseWithFail0, so a hotspot with something of its own to say says it, and everything else still falls through to the game's general refusal. A hotspot does not have to be in the picture. A HUD widget can own one and hand it to cGame.InvokeVerb0 — see cHotspotPanel0 under the widget system. 3Trigger p // scene.trigger.go p p type Trigger struct { p Name string p When Condition p Do Action p Once bool p } Triggers arm on scene enter. On every idle frame (script slot free) the engine samples cWhen0 and queues cDo0 on a brising edge0 (false → true). cOnce: true0 fires at most once per arming. A cnil0 cWhen0 or cDo0 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. 3Item p // item.def.go p p type Item struct { p Name string p Sprite *Sprite // a picture, or frames on a sheet;» p see Character p Description Text p Lore Text // a paragraph for a dossier screen» p , once the item is known p p OnUseSelf Action p OnUseWith map[*Item]Action // this item used on that item p } cGive(key)0 appends the item to the inventory, which remembers it by cName0, and the item becomes known (see cState.Known0). Clicking a hotspot with an item selected resolves in this order: 1. chotspot.OnUseWith[item] 2. chotspot.OnUseWithAny 3. cGame.UseWithFail(item, hotspot) 4. otherwise flash "Nem ehhez." and deselect cItem.OnUseWith0 is an item used on an iitem0: with one in hand, clicking another in the cInventoryBar0 runs whichever of the two pairs them. 3Inventory p // item.inventory.go p p func NewInventory() *Inventory p func (i *Inventory) Add(name string) p func (i *Inventory) Remove(name string) p func (i *Inventory) Has(name string) bool p func (i *Inventory) Select(name string) // "" clears p func (i *Inventory) Selected() string p func (i *Inventory) Items() []string // copy Runtime state owned by cGame0, not a manager. Mutated by cGive0 / cTakeAway0 and by clicks on cInventoryBar0. 3Character p // character.def.go p p type Character struct { p Name string p Label Text // what the UI shows; empty = N» p ame p Lore Text // a bio for a dossier screen, » p once the character is known p Poses map[Pose]*Sprite // one Sprite per Pose the art » p covers; none draws the placeholder p Speed float64 // px/sec, default 60 p SpeechColor color.Color p Delivery Delivery // DeliveryBubble (default) or » p DeliveryLog p Start Point // where Game.Player stands by » p default p W, H float64 // placeholder size hint p } p p type Delivery int p const ( p DeliveryBubble Delivery = iota // speech bubble over the scene, plus th» p e log p DeliveryLog // the log alone p ) p p func (c Character) DisplayName() Text // Label, or Name p p type Speaker struct { // a voice with no body: a tape, a » p room, a narrator p Name string p Label Text p Delivery Delivery p Color color.Color p Lore Text // a bio for a dossier screen, once» p the voice is known p } p p type Talker interface{ /* *Character or *Speaker */ } p p func (c *Character) Say(text Text) Action // Say(c, text» p ): who, then what p func (c *Character) Reply(answer func(*State) Text) Action p func (s *Speaker) Say(text Text) Action p func (s *Speaker) Reply(answer func(*State) Text) Action p func (g *Game) Speaker(name string) (Speaker, bool) // a character's voice,» p or a bodiless one p func (g *Game) SpeakerLabel(name string) Text p func (g *Game) SpeechColor(name string, fallback color.Color) color.Color p func (g *Game) ItemLabel(name string) Text // Description, or Name p func (g *Game) DeliveryOf(name string) Delivery p p type Sprite struct { // a picture, or a sequence of them p Sheet *Asset p Frames []Rectangle // cut from the sheet, in order; em» p pty = the whole sheet p FrameTime float64 // seconds per frame p Loop bool p Faces Facing // the way the artwork looks; mirro» p red for the other p } p p type Pose int p const ( p PoseIdle Pose = iota p PoseWalk p PoseTalk p PoseWait p ) p p type Facing int p const ( p FacingRight Facing = iota p FacingLeft p ) • cDelivery0 is where a character reaches the player, and cSay0 obeys it: a cDeliveryLog0 character gets the log line without the bubble. No domain needs a second spelling of cSay0 for that. (It was called cVoice0 until a voice became a recording; see Audio.) • A voice with no body at all is not a character. A radio, a cassette, a narrator — anything that speaks but never stands in a scene — is a cSpeaker in cSpeakerManager0: cName0, cLabel0, cDelivery0, cColor0, and nothing about walking or being drawn. cSay0, the dialog box, the chat log and the bubble resolve a name through cGame.Speaker(name)0, characters first and speakers second, so a line does not care which kind said it; cGame.DeliveryOf0, cGame.SpeakerLabel and cGame.SpeechColor0 are the three answers a widget asks for. A name in neither registry speaks in a bubble under its own name. • cLabel0 is what a HUD prints: a code name, a title, a shout. cStart0 is where cGame.Player0 stands in a scene that lists no actors of its own. With cGame.WalkOnClick0, cGame.Player0 is also who walks: a left click on empty floor sends that character there, routed through the scene's walkboxes. It is off by default, so a game says so when it wants it. • No sprite on disk → stylised placeholder: humanoid when cW < H0, quadruped otherwise. An cRGBA0 cSpeechColor0 also colors the placeholder body and the bubble text. • bSprites and poses.0 A cSprite0 is a picture or a sequence of them: a cSheet0 and the cFrames0 cut from it, in order, at cFrameTime0 seconds each, looping or holding the last; no cFrames0 means the sheet is the one frame, so a still item and a walking character are the same type. cPoses0 gives a character one cSprite0 per thing it can be seen doing, and the engine picks the pose: cPoseWalk0 while it moves, cPoseTalk0 while its line is up, cPoseWait0 once it has stood still for eight seconds, cPoseIdle0 otherwise — or whatever cSetPose holds, until the character next walks or speaks. A pose the art does not cover falls back to cPoseIdle0; no cPoseIdle0 draws the placeholder. Frames are blitted with cSubImage0 and scaled to cW×H0. • Direction is not a second sheet. A cSprite0 says which way it cFaces0, the character remembers the way it last moved, and the frame is mirrored when the two differ; the facing survives a save. 3Dialog p // dialog.def.go p p type Dialog struct { p Name string p Start string // initial node; "" = first in slice p Nodes []DialogNode p } p p type DialogNode struct { p Name string p Lines []DialogLine // one per click p Choices []DialogChoice p } p p type DialogLine struct { p Speaker Talker // a *Character or a *Speaker p Text Text p } p p type DialogChoice struct { p Text Text p Show Condition // nil = always visible p Once bool // hide after the first pick p Actions []Action p } p p func (d Dialog) Node(name string) (DialogNode, bool) The flow: cRunDialog(dialog)0 blocks until the dialog closes → cDialogBox0 shows one line per click → after the last line the visible cChoices0 appear → picking one runs cSeq(choice.Actions...)0 and, for cOnce0, records it with cState.NoteTalked0. cGotoNode("other")0 jumps inside the dialog, cEndDialog() closes it. 3Script p // script.def.go p p type Script struct { p Name string p Actions Action // usually Seq(...) p } A named composite action, for reusing a cutscene from several call sites. Run it with cRunScript(script)0. 3Verb p // verb.def.go p p type Verb struct { p Name string // canonical id, "look" p Label Text // display label, "Look at" / "Nézd" p Default Action // runs when the hotspot has no hand» p ler p } p p g.VerbManager.Register(inkwell.Verb{ p Name: "push", Label: "Lökd", p Default: inkwell.Say(player, inkwell.Text{{Lang: "hu", Text: "Nem mozdul» p ."}}), p }) The verb bar and verb wheel read cVerbManager.Names()0 every frame, so a verb added at runtime appears immediately. r────────────────────────────────────────────────────────────────────────────── 3Stage p // stage.def.go p p type Stage struct { p Name string p Title Text p World bool // this Stage is the game itself p Background *Asset // full-screen image for a Stage that is not the wor» p ld p Music *Music // a track for this Stage; nil keeps what is playing p Transition Transition // how this Stage gives way to the next; nil cuts p Hold float64 // seconds to show, then move on to Next p Next *Stage p Skippable bool // Space, Enter or a click ends the Hold early p OnEnter Action p OnLeave Action p } A cStage0 is one full-screen thing the game can be showing, and exactly one of them is showing at a time. A main menu is a Stage, a settings screen is a Stage, a credits card is a Stage — and so is the game itself, which is the point: the world is not the thing every other screen floats over, it is one of the screens. That is the difference between a Stage and a Scene. A Scene is a place in the fiction, and the engine always has one; walking from a room into an alley is a Scene change iinside0 the world. A Stage is what owns the frame. Off the world Stage the scene is not painted, characters do not walk, triggers do not fire and a click reaches no hotspot — which is what a game showing its main menu wants, and what it could not have while the only way to hold the world still was a script that never finished. Widgets need no new field to follow this, because cOnStage(stage)0 is a cCondition0 like any other: p game := StageManager.Register(&inkwell.Stage{Name: "game", World: true}) p mainMenu := StageManager.Register(&inkwell.Stage{Name: "main_menu"}) p p WidgetManager.Register(&inkwell.Menu{ p Name: "main_menu", p When: inkwell.OnStage(mainMenu), p Sticky: true, p Root: inkwell.MenuItem{ … }, p }) A HUD hides itself off the world exactly the way it already hides itself for a cutscene — one condition, no wrapper, no cif0 at the top of a cDraw0. cGoToStage0, cRestart0 and cLoadSlot0 all land at the top of the inext0 frame rather than where they are called. Replacing cState0, the inventory and every character underneath the action that asked for it is not something that action should have to survive, and one deferred point lets a menu row say the whole of what it means in one cSeq0: p Do: inkwell.Seq(inkwell.Restart(), inkwell.GoToStage(game)), bLeaving through a Transition.0 A cStage0 may name how it gives way: cTransition0 is an cinkwell.Transition0, and a change out of the Stage — a cGoToStage0, or its cHold0 running out — goes through it. cFade(seconds)0 is the one the engine ships (cFadeTo0 for a colour other than black); nil is a cut, which is what a menu and its sub-screens want. The whole pending change lands on the covered frame, a cRestart0 or cLoadSlot0 asked for in the same breath included, so the screen still showing during the fade is over the world it was over. Scene changes go through the same machinery with their own quarter-second fade. bA card that moves on.0 cHold0 shows a Stage for a number of seconds and then goes to cNext0, through the Stage's cTransition0 — which is all a splash card or a title sequence is: p StageManager.Register(&inkwell.Stage{ p Name: "splash_studio", p Transition: inkwell.Fade(0.5), p Hold: 2, p Skippable: true, p Next: splashTitle, p }) The clock starts once cOnEnter0 is done: the hold is one action on the same queue as cOnEnter0, so it lands like any other change, at the top of a frame. cSkippable0 lets the player cut it short — Space, Enter or a click moves on to cNext0 at once, through the same cTransition0, and the key or the click is claimed so nothing underneath reads it. It is one action rather than a cWait0 and a cGoToStage0 for exactly that reason: a cGoToStage0 still queued after a skip would fire on whatever Stage was showing by then. cValidate0 rejects a cNext0 nobody registered and a cHold0 with no cNext0. A game whose cStartStage0 is the world starts a session at boot: the start scene is entered and cStartScript0 is queued. A game that opens on a menu starts one when the player says so — which is all cRestart0 is. cStarted()0 reports whether one exists, and is what a "Resume" or "Save" row is enabled by. 3Achievement p // inkwell/achievement/achievement.go p p type Achievement struct { p Name string // the key a platform knows it by — the Steam API name p Title Text // what a screen shows for it p Text Text p When Condition // what earns it, evaluated on the world stage every f» p rame p } An cAchievement0 is something the player is recognised for, declared as content: a name, a title and a line in every language the game speaks, and the cCondition0 that earns it — a flag raised, an item in hand, a scene entered. It is registered like any other entity and handed over as cGame.AchievementManager0; cValidate0 rejects one with no cWhen0. The engine evaluates every achievement on each frame the world is showing, a running script included, and hands the ones that have just come true to every cUnlocker0 among the cExtensions0 (see Extensions). It keeps no record of its own beyond a per-process set that stops a condition that stays true from firing every frame: a platform that has a record, Steam, answers a repeated cUnlock idempotently, and a game without one has nothing to persist. A cLoad0 that finds a condition already met therefore reports it again on the next run of the process, which is what lets an achievement earned offline reach the platform. cTitle0 and cText0 are what a platform's own listing repeats — the words typed into the Steamworks partner site are these — and what a screen of the game's own would show; nothing in the engine draws them yet. r────────────────────────────────────────────────────────────────────────────── 2The action system 2───────────────── p // action.def.go p p type Action interface { p Start() Runner p } p p type Runner interface { p Tick(ctx *Ctx) Status p } p p type Status int p const ( p StatusRunning Status = iota p StatusDone p StatusFailed p ) p p type Ctx struct { p Game *Game p DT float64 // seconds since last tick p Scene *Scene // current scene, may be nil p Hotspot *Hotspot p Item *Item p } An cAction0 is an bimmutable spec0 — safe to keep in a cHotspot.OnLook0 field and reuse on every click. A cRunner0 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 cPar(...)0 inside a single action. cStatusDone0 advances a cSeq0. cStatusFailed0 short-circuits the whole branch — this is how cRequireItem0 aborts a sequence. 3Built-in actions t┌─────────────────────────────────────┬──────────────────────────────────────┐ t│h Constructor t│h Behaviour t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSeq(a ...Action)0 t│0 Children in order. Nested cSeq0 are t│ t│0 t│0 flattened, cnil0 ignored. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cPar(a ...Action)0 t│0 Children together; done when all are t│ t│0 t│0 done. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cIf(c Condition, then Action, els0 t│0 cthen0 when cc0 is true, otherwise t│ t│0 c...Action)0 t│0 cSeq(els...)0. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cWait(seconds float64)0 t│0 Block for cseconds0. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSay(speaker Talker, text Text)0 t│0 Line above the speaker + chat-log t│ t│0 t│0 entry. Click to skip. Duration t│ t│0 t│0 scales with the resolved text's t│ t│0 t│0 length, 1.2s floor. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cReply(speaker Talker, answer0 t│0 cSay0 with the line decided when it is t│ t│0 cfunc(*State) Text)0 t│0 spoken: canswer0 gets the cState0 and t│ t│0 t│0 returns the cText0 for this moment, so t│ t│0 t│0 a speaker answers from what the t│ t│0 t│0 player has done without a dialog t│ t│0 t│0 node per case. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cGoTo(scene *Scene)0 t│0 Switch scene with a fade. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cBack()0 t│0 Return to cPreviousScene()0. No-op t│ t│0 t│0 when there is nowhere to go back to. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cWalk(character *Character, to0 t│0 Move at cCharacter.Speed0; done on t│ t│0 cPoint)0 t│0 arrival. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSetPose(character *Character, pose0 t│0 Hold a character in a pose until it t│ t│0 cPose)0 t│0 next walks or speaks. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cGive(item *Item)0 t│0 Add to inventory, and mark the item t│ t│0 t│0 known. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cLearn(entity Knowable)0 t│0 Mark an Item, Character or Speaker t│ t│0 t│0 known — see cState.Known0. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cTakeAway(item *Item)0 t│0 Remove from inventory. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cRequireItem(item *Item)0 t│0 Missing item → flash cMsgNeedItem0 and t│ t│0 t│0 fail. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSetFlag(name)0 / cClearFlag(name)0 t│0 cState.SetFlag0 / cState.ClearFlag0. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSetVar(name string, v any)0 t│0 cState.SetVar0. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSetVarIfEmpty(name string, v any)0 t│0 cState.SetVar0, but only while nothing t│ t│0 t│0 has written it yet — what a scene's t│ t│0 t│0 cOnEnter0 wants for a first-arrival t│ t│0 t│0 value. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cNoting(name string, v any, inner0 t│0 Write a var for exactly as long as t│ t│0 cAction)0 t│0 cinner0 runs, and clear it afterwards. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cUseTheme(theme *Theme)0 t│0 Switch the active theme from inside t│ t│0 t│0 a script, and save the settings. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cUseLang(lang string)0 t│0 Switch the language every cText0 t│ t│0 t│0 resolves into, and save the t│ t│0 t│0 settings; reaches the next frame. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cGoToStage(stage *Stage)0 t│0 Show another Stage, at the top of t│ t│0 t│0 the next frame. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cGoBack()0 t│0 Show the Stage that was showing t│ t│0 t│0 before this one. No-op when there is t│ t│0 t│0 none. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cRestart()0 t│0 Begin a new session, at the top of t│ t│0 t│0 the next frame. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cLoadSlot(slot int)0 t│0 Restore a saved session, at the top t│ t│0 t│0 of the next frame. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSaveSlot(slot int)0 t│0 Write the session to a slot, where t│ t│0 t│0 it is called. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cQuit()0 t│0 Close the window at the end of the t│ t│0 t│0 frame. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cFlash(text Text)0 t│0 Put a line in the status strip for t│ t│0 t│0 ~2s — cGame.FlashLine0 as an action. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cPlayMusic(music *Music)0 / t│0 cAudioPlayer0 calls; a track loops t│ t│0 cStopMusic()0 / cPlaySound(sound0 t│0 when its cMusic.Loop0 says so. t│ t│0 c*Sound)0 t│0 t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSetVoice(on bool)0 t│0 Switch recorded lines on or off, and t│ t│0 t│0 save the settings. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cRunDialog(dialog *Dialog)0 t│0 Start a dialog; blocks until it t│ t│0 t│0 closes. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cEndDialog()0 t│0 Close the active dialog. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cGotoNode(node string)0 t│0 Jump inside the active dialog. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cRunScript(script *Script)0 t│0 Run a registered cScript0. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cShowEnd(text Text)0 t│0 End-card overlay; never finishes. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cCustom(fn func(*Ctx) Status)0 t│0 Escape hatch for an arbitrary t│ t│0 t│0 callback. t│ t└─────────────────────────────────────┴──────────────────────────────────────┘ 3Custom actions For long-running state (animation, tween, poll) implement both interfaces: p type fadeAction struct{ target string; duration float64 } p p func (a *fadeAction) Start() Runner { return &fadeRunner{spec: a} } p p type fadeRunner struct { p spec *fadeAction p elapsed float64 p } p p func (r *fadeRunner) Tick(ctx *Ctx) Status { p r.elapsed += ctx.DT p if r.elapsed >= r.spec.duration { p return StatusDone p } p return StatusRunning p } For a one-shot mutation, cCustom0 is shorter: p bumpScore := inkwell.Custom(func(ctx *inkwell.Ctx) inkwell.Status { p cur := ctx.Game.State.Var("score").(int) p ctx.Game.State.SetVar("score", cur+10) p return inkwell.StatusDone p }) r────────────────────────────────────────────────────────────────────────────── 2Conditions 2────────── p // condition.def.go p p type Condition interface { p Eval(ctx *Ctx) bool p } t┌─────────────────────────────────────┬──────────────────────────────────────┐ t│h Constructor t│h Meaning t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cNot(c)0 t│0 c!c0 t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cAnd(cs ...)0 t│0 all true t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cOr(cs ...)0 t│0 at least one true t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cFlag(name)0 t│0 cState.Flag(name)0 t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cHasItem(item)0 t│0 cInventory.Has(name)0 t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cSelectedItem(item)0 t│0 cInventory.Selected() == name0 t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cInScene(scene *Scene)0 t│0 current cCtx.Scene0 is this one t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cOnStage(stage)0 t│0 the named Stage is the one showing t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cOnWorld()0 t│0 the Stage showing is the world, t│ t│0 t│0 whichever that is t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cStarted()0 t│0 a session exists — begun or loaded t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cKnows(entity Knowable)0 t│0 cState.Known(entity) > 00 t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cVarEq(name, v)0 t│0 cState.Var(name) == v0, Go equality t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cGiven{Flag, NotFlag, Item, NotItem,0 t│0 a literal: every set field must t│ t│0 cVar, Is}0 t│0 hold; cGiven{}0 holds always t│ t└─────────────────────────────────────┴──────────────────────────────────────┘ Conditions are stateless and evaluated lazily, including inside cIf0 and cDialogChoice.Show0. cGiven0 is the one that is a struct rather than a constructor, for the place a condition is read most: a dialog choice, where "only if the player holds the tape and has not been told yet" is two stacked fields — cGiven{Item: cmysteryTape, NotFlag: "dex_told"}0 — rather than a call tree. Every set field has to hold, so a cGiven0 is an cAnd0 of its fields, and cOr(Given{…}, Given{…})0 is still how alternatives read. r────────────────────────────────────────────────────────────────────────────── 2World state 2─────────── p // state.def.go p p func NewState() *State p p func (s *State) Flag(name string) bool p func (s *State) SetFlag(name string) p func (s *State) ClearFlag(name string) p p func (s *State) Var(name string) any p func (s *State) SetVar(name string, v any) p p func (s *State) Visited(name string) int p func (s *State) NoteVisit(name string) // engine bumps on scene » p enter p p type Knowable interface { Named; TypeLabel() string } // an Item, a Charact» p er, a Speaker p p func (s *State) Known(entity Knowable) int // 1, 2, 3… in order of d» p iscovery; 0 for not yet p func (s *State) NoteKnown(entity Knowable) // Say notes the talker, » p Give the item, Learn anything p p func (s *State) Talked(node string) int p func (s *State) NoteTalked(node string) // DialogBox bumps on a O» p nce pick Flags carry puzzle state (c"cupboard_open"0). cSetVar0 takes any value — strings, numbers for scores, struct references. cTopBar0 reads vars by name for score and time. cKnown0 is the player's record of who and what they have met, kept for a dossier or codex screen. The engine fills it in on its own from two rules — a character or a speaker is known once it has said a line, an item once it has been in the inventory — and a script says cLearn(entity)0 for the rest: a name read off a list, a face seen through glass. The value is the order of discovery, so a screen sorted by it reads as the player's own history; cKnows(entity)0 is the same fact as a condition. An item and a character may share a cName0, because the kind is part of the key. cVisited0 and cTalked0 back the "Once" semantics. r────────────────────────────────────────────────────────────────────────────── 2The widget system 2───────────────── The HUD is a tree of widgets registered into cg.WidgetManager0. Built-ins cover the classic adventure UI; your own widgets plug in without touching the library. p // widget.def.go p p type Widget interface { p Named p Tick(ctx *UICtx) p Draw(dst *ebiten.Image, ctx *UICtx) p } p p type Layer int p const ( p LayerScene Layer = 0 // over the picture, under the HUD p LayerPanel Layer = 100 // the plate the HUD sits on p LayerHUD Layer = 200 // verbs, inventory, status line, top bar p LayerSpeech Layer = 300 // speech bubbles p LayerDialog Layer = 400 // the dialog box p LayerDebug Layer = 450 // the developer's overlay p LayerMenu Layer = 500 // radial verbs, menus p LayerCurtain Layer = 600 // end cards, fades p LayerCursor Layer = 700 // the pointer p ) p p type Layered interface { p Layer() Layer p } p p func LayerOf(w Widget) Layer // Layered, else LayerHUD p p type Gated interface { p VisibleWhen() Condition p } p p func WidgetVisible(ctx *Ctx, w Widget) bool // Gated with a false condition» p = off p p type Awake interface { p TicksDuringScript() bool p } p p func WidgetAwake(w Widget) bool // ticks on the frames a script holds p p type UICtx struct { p Game *Game p DT float64 p } p p type MouseButton int p const ( p MouseButtonLeft MouseButton = iota p MouseButtonRight p ) p p type Size struct{ W, H int } p p type Align int p const ( p AlignLeft Align = iota p AlignCenter p AlignRight p ) A widget handles its own input, hit-testing, state and drawing. There is no layout layer — widgets carry their own cBounds0 (or compute them, like cRadialVerbs0). 3Z-order and input • cDrawb runs from the bottom layer up0, so a widget on a higher layer is painted on top. Inside one layer, registration order decides. • cTickb runs from the top layer down0, so the widget on top gets the click first. It claims the event with cctx.Game.Input.ConsumeLeft()0 / cConsumeRight()0; widgets below then see cLeftClicked() == false0. • bA widget declares its layer0; it does not inherit one from the order it was registered in. Every built-in implements cLayered0; a widget that does not sits on cLayerHUD0. cCursor0 is on cLayerCursor0: always on top, never claims clicks, whenever it was registered. • Layers are what let a domain register its widgets bone file at a time0 — an cinit()0 per widget, in whatever order the file names fall — without the HUD coming out shuffled. • A wrapper widget that forwards to an inner one should return cLayerOf(inner) from its own cLayer0, so wrapping does not move the widget. • bEvery built-in also carries a cWhen Conditionb.0 A widget whose condition is false neither ticks nor draws nor blocks a click, so a HUD is hidden for a cutscene by saying so on the widgets — cWhen: inkwell.VarEq("mode", "play")0 — rather than by unregistering or wrapping them. A domain widget joins in by implementing cVisibleWhen() Condition0; one that does not is always live. • bA running script has the frame to itself0 — no widget ticks until it is done, which keeps a click from racing a cutscene already under way. A widget that implements cAwake0 opts out: it ticks on those frames too, bbefore0 the script's runner, so a click or key it consumes is not also read as “skip this line”. Two built-ins say yes: cMenu0, which could not be closed once a cutscene started, and cDialogBox0, because a conversation bis0 the running script — cRunDialog0 holds the frame while the dialog is up, so the box has to tick inside it or its lines never advance. • After every widget ticked, the click is offered to chandleSceneInput0: hotspot interactions, and, with cGame.WalkOnClick0, a click on no hotspot walks cGame.Player0 there. It calls cwalkCharacter0 directly rather than queueing a cWalk0, because a queued walk would be a script and the next click could not redirect it. A consumed click makes all of that a no-op. 3Built-in widgets One file each, cwidget..go0. Zero-value fields fall back to sensible defaults. p // widget.panel.go — colored rectangle, optional border; backdrop for other » p widgets p type Panel struct { p Name string p Bounds Rectangle p BG color.Color // nil -> Theme.PanelBG p Border int // 0 = none p BorderColor color.Color // nil -> Theme.DialogBorder p Blocks bool // opaque to clicks p } p p // widget.status.go — one line: the active flash message, else the hover hin» p t p type StatusLine struct { p Name string p Y int p Align Align // default AlignCenter p ScreenWidth int // 0 -> Game.Width p } p p // widget.verb_bar.go — SCUMM verb panel, Cols x ceil(N/Cols) grid from Verb» p Manager.Names() p type VerbBar struct { p Name string p Origin Point p Cols int // default 2 p Button Size p Gap Point p PanelBG bool p } p p // widget.verb_radial.go — verb coin or permanent wheel p type RadialVerbs struct { p Name string p Trigger MouseButton // default MouseButtonRight p Radius float64 // default 40 p p AlwaysVisible bool // permanent wheel at Center; Trigger ignored p Center Point p p Labels map[string]Text // shorten a label so it fits the disk p } p p // widget.inventory.go — item slots; hover sets the description, click toggl» p es selection p type InventoryBar struct { p Name string p Origin Point p Slots int p Cols int // 0 -> Slots (single row) p SlotSize int p Gap int p PanelBG bool p } p p // widget.speech.go — draws whatever Game.SetSpeech last set, above the spea» p ker's head p type SpeechBubble struct { p Name string p MaxWidth int // wrap width, default 200 p Padding int // default 3 p OffsetY int // lift above head p FallbackY int // anchor when the speaker is unknown p } p p // widget.dialog_box.go — dormant unless a dialog runs; then consumes every » p click; p // Awake, because RunDialog is the script holding the frame p type DialogBox struct { p Name string p Bounds Rectangle // default Rect(0, 140, Width, 60) p LineHeight int // default 14 p Padding int // default 6 p } p p // widget.end_card.go — fullscreen overlay from ShowEnd; consumes every clic» p k p type EndCard struct{ Name string } p p // widget.cursor.go — crosshair, or the selected item's sprite when it has o» p ne p type Cursor struct{ Name string } p p // widget.hotspot_debug.go — a coloured plate and the name over every clicka» p ble: hotspot, exit, character, item, panel target p type HotspotDebug struct { p Name string p When Condition p Enabled bool p ToggleKey ebiten.Key // 0 -> ebiten.KeyF1 p Colors map[DebugKind]color.Color // per-kind override; alpha honoure» p d p } p // Targeted — a widget with clickables of its own offers them via DebugTarge» p ts(g) []DebugTarget; InventoryBar and HotspotPanel implement it p p // widget.top_bar.go — left: LeftText or Scene.Title (+ NoteVar) | center: s» p core | right: time p type TopBar struct { p Name string p When Condition p Height int // default 12 p LeftText Text p ScoreVar string // State.Var key; "" = no score p ScoreMax int // >0 -> "Score: X/MAX" p TimeVar string // State.Var key; "" = no time p NoteVar string // State.Var appended after an em dash: "» p ALLEY — paused" p } p p // widget.menu.go — a nested list in a window; see "The menu system" p type Menu struct { p Name string p When Condition p Root MenuItem // first page; its Label titles the windo» p w p Bounds Rectangle // zero = the right half of the screen p ToggleKey ebiten.Key // zero = no key, Toggle() only p Sticky bool // the menu IS the screen; cannot be clos» p ed p LineHeight int // default GlyphH + 2 p Padding int // default 6 p } p p // widget.character_panel.go — portrait, role badge, stat rows; p // auto-hides when the character is not an actor in the current scene p type CharStat struct { p Label Text p VarKey string // State.Var(VarKey), fmt-printed p } p p type CharacterPanel struct { p Name string p Bounds Rectangle p Character string p Title Text // "PLAYER", "NPC", "GUIDE", … p Stats []CharStat p } p p // widget.chat_log.go — Game.Messages() with per-kind colors, newest at the » p bottom p type ChatLog struct { p Name string p Bounds Rectangle p LineHeight int // default 10 p Padding int // default 3 p ShowBorder bool p p Speaker SpeakerStyle // SpeakerInline | SpeakerHeader | Speake» p rNone p Deliveries []Delivery // keep only replies delivered these ways» p ; nil = all p Resting Talker // who the header carries before anybody » p speaks p } p p // widget.key_binding.go — a key and what it does; draws nothing p type KeyBinding struct { p Name string p When Condition p Key ebiten.Key p Do Action p DuringScript bool // answer on the frames a running script holds p } p p // widget.text_card.go — a block of lines centred on the screen p type TextCard struct { p Name string p When Condition p Lines []Text // an empty Text is a blank line p Align Align p LineHeight int // 0 -> GlyphH + 2 p Backdrop bool // fill the screen behind the block p } p p // widget.letterbox.go — the bars that crop the picture while a cutscene pla» p ys p type Letterbox struct { p Name string p When Condition p Bar float64 // each bar's height; 0 -> 18 p Color color.Color // nil -> black p } p p // widget.hotspot_panel.go — a region of the HUD that answers the verbs p type HotspotPanel struct { p Name string p When Condition p Bounds Rectangle // the region the panel claims p Hotspots func(g *Game) []Hotspot // its targets, in screen coordina» p tes p } Notes: • cSpeechBubble0 text color: cCharacter.SpeechColor0, else cTheme.SpeechDefaultText0. • cDialogBox0 hover color is cTheme.DialogChoiceHover0; cOnce0 choices disappear after their first pick. • cHotspotDebug0 toggles at runtime with F1; it paints a plate of a different colour per kind over every clickable, the name on the scene's own targets only (a widget already prints its labels), on cLayerDebug0, under the menus. • cTopBar0 skips empty fields, so it degrades to a two- or one-section layout. • cCharacterPanel0 and cChatLog0 reuse placeholder portraits and the theme's chat-log colors. A cLogResponse0 line takes the speaker's own cCharacter.SpeechColor0 where the character carries one, so a log with several voices in it stays readable as several voices. • With a small cChatLog0, set cGame.MaxLogLines0 to about 64. • A cChatLog0 can be given over to part of the conversation rather than all of it. cDeliveries0 keeps only the replies delivered one of the ways it lists, which is how a game with a radio, a tape or a narrator hands that voice a panel of its own — without the widget having to know what the thing is. The engine already knows how a character reaches the player (cCharacter.Delivery0), so the filter is that fact and not a second one to keep in step with it. cSpeakerHeader0 suits such a panel: the name reads as a heading rather than as a word repeated down the margin. • cLetterbox0 sits on cLayerScene0, so it draws over the picture and under the HUD. A game that also hides its HUD for a cutscene gets the whole convention out of two registrations sharing one condition. • cHotspotPanel0 makes a region of the HUD answer the verbs, the way a region of the picture already does. It owns nothing but a hit-test: the hotspots it hands back go through cGame.InvokeVerb0, so a device mounted on the panel gets look, use, talk and take — and the use-with flow, the log lines and the nothing-happens fallback — out of the same code a painted hotspot does. This is what keeps a game's own HUD from re-implementing the engine: without it, a widget with two clickable slots has to read the selected verb, check for a held item, consume the click and answer every combination itself, in a switch, with the game's own writing trapped inside it. cHotspots0 is a function rather than a slice because what is mounted on a panel changes as the game does, and a widget that asks each time needs no invalidation. The panel draws nothing — embed it and declare a cDraw0 of your own. 3The clickBlocker contract Widgets that own a fixed area should not let a cRadialVerbs0 menu pop up over them: p type clickBlocker interface { p BlocksClickAt(p Point) bool p } Implemented by cVerbBar0, cInventoryBar0, cDialogBox0, cTopBar0, cCharacterPanel0, cChatLog0, cRadialVerbs0 itself while visible, and cMenu0 — its window, while it is open. Implement it on your own widgets to mark them as solid input zones. 3Registration helpers p // widget.preset_scumm.go, widget.preset_radial.go, widget.preset_rich.go p p func RegisterDefaultUI(g *Game) // verb bar + inv» p entory p func RegisterRadialVerbUI(g *Game) // verb coin + w» p ider inventory p func RegisterRichUI(g *Game, player, npc *Character) // top bar + pane» p ls + chat log + wheel Pass c""0 for a character name to skip its cCharacterPanel0. All three register in conventional Z-order: debug overlay at the back, cursor at the front. 3Custom widgets Anything implementing cWidget0 works. Read cctx.Game0, consume input via cctx.Game.Input0, draw with Ebitengine. p type Minimap struct { p Name string p Bounds inkwell.Rectangle p } p p func (m *Minimap) GetName() string { return m.Name } p func (m *Minimap) Tick(ctx *inkwell.UICtx) {} p func (m *Minimap) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) { p // render scene thumbnail, mark NPCs, … p } p p g.WidgetManager.Register(&Minimap{Name: "minimap", Bounds: inkwell.Rect(220,» p 4, 96, 56)}) 3The menu system cMenu0 is a nested list in a window: a page of rows, any of which can open a page of its own, as deep as the tree goes. It is a widget like any other — on cLayerMenu0, gated by cWhen0, registered from its own file — and it is the whole of the menu system. A debug menu, a pause menu and an in-fiction device screen are this one widget with a different cRoot0. A row and a page are bthe same type0. cMenuItem.Label0 names the row on its parent's page and titles the page it opens, which is what makes the breadcrumb in the title bar fall out for free: p // widget.menu.go p p type MenuItem struct { p Label Text p Value func(*Ctx) Text // right-hand column: a count, a target,» p "held" p Items []MenuItem // the page this row opens, as authored p Expand func(*Ctx) []MenuItem // the page this row opens, read out of » p the game p Body Text // the page this row opens, as a paragra» p ph to read p Do Action // what Enter queues on a leaf p Enabled Condition // false dims the row and refuses Enter p KeepOpen bool // leave the menu up after Do p } p p func FixedValue(t Text) func(*Ctx) Text // a Value that does not depend on» p the game A row with none of cItems0, cExpand0 or cBody0 is a leaf, and Enter hands its cDo0 to cGame.Do0. The two page fields are one decision: cItemsb is what an author typed, cExpandb is what the game knows.0 An cExpand0 page is rebuilt while it is open, so a list of what is clickable in the current scene rewrites itself when the player leaves the scene; an cItems0 page costs nothing per frame. cValue0, cExpand and cEnabled0 all take the same c*Ctx0 an action or a condition takes, so cctx.Scene0 is there without being asked for. cBody0 makes the page a row opens a paragraph rather than rows: the cText0 wrapped to the window in the player's language, scrolled with the same keys, with nothing to pick. A codex entry, a letter, a bio. A row with a cBody0 shows the same c→0 as a row with a page, and its cDo0 is never run. The menu answers what a list has always answered to, and bclaims every key it buses0 so nothing underneath sees it: t┌─────────────────────────────────────┬──────────────────────────────────────┐ t│h t│h t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 up / down, wheel t│0 walk the rows, wrapping t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 PageUp / PageDown / Home / End t│0 jump t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 Enter, right t│0 open the row, or run it t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 Backspace, left, right mouse button t│0 back a level; at the root, close t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 Escape, the c×0 in the title bar t│0 close t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 hover t│0 move the cursor — only when the t│ t│0 t│0 pointer itself moved, so it does not t│ t│0 t│0 fight the keys t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 left mouse button t│0 open the row under the pointer t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 c←0 in the title bar t│0 back a level t│ t└─────────────────────────────────────┴──────────────────────────────────────┘ Only clicks inside the window are consumed, so a menu down one side of the screen leaves the other side playable. cToggleKey0 opens and closes it; zero binds no key and leaves that to cOpen0, cClose0 and cToggle0 — which is how a HUD button would drive one. cSticky0 makes the menu the screen rather than a window over one: it is open whenever it is visible, Escape and back-at-the-root do nothing, cToggleKey0 is ignored, and the close box is not drawn. A main menu at boot has nothing behind it to close onto, and a menu that can be dismissed into an empty frame is a way to lose a game rather than to leave one. A sticky menu is what a Stage is usually made of, and its key legend says so — it offers no ESC. Colours come from the dialog tokens of the active cTheme0, so a menu needs no theme work to look like the rest of the game; a disabled row is drawn darkened rather than in a token of its own. cMenuEmpty0 (a page with no rows) and cMenuKeys0 (the legend along the foot) are engine strings, in ci18n.strings.go0. 3The debug menu cDebugMenuRoot0 is the one use of the menu system the library ships: what the game is made of, and what is clickable in the scene the player is standing in. It is bdata, not a widget0 — the menu is the widget: p g.WidgetManager.Register(&inkwell.Menu{ p Name: "debug_menu", p Root: inkwell.DebugMenuRoot, p When: whenDebug, p ToggleKey: ebiten.KeyD, p }) p DEBUG p ├── Scenes every registered scene, in registration order p │ └── value: "here", or ×N visits · Enter teleports p ├── Items every registered item p │ └── value: "held" / "in hand" p │ ├── give · take away · select p │ ├── use on self only when the item has an OnUseSelf p │ └── use with → the item's own OnUseWith pairs p ├── Action points the current scene's hotspots, exits included, in hit-test» p order p │ └── value: its CursorKind p │ ├── only the verbs the hotspot answers p │ └── use with → the hotspot's OnUseWith pairs p ├── Exits the current scene's exits, as the graph sees them p │ └── exit: value: which edge it sits on p │ ├── go p │ └── needs value: on / off p │ └── set · clear p └── EXPLORER the Game itself, field by field — see below The rows name bregistry keys0, not the prose a player reads: a debug menu is read by whoever writes the content, and cnoodle_house0 says more there than c"NOODLE'S HOUSE — KITCHEN"0 does, in any language. Every verb goes through cinvokeHotspotVerb0, the same path a click takes, so the chat log and the status line read as if the player had done it. A cuse with0 row hands the item over first — the point is to watch the pairing run, not to be reminded that the player never picked the thing up. Nothing registers it, cRegisterDefaultUI0 included: a debug menu is something a game asks for, and gates on whatever its debug builds set. A game that wants sections of its own builds a root around it: p Root: inkwell.MenuItem{ p Label: inkwell.DebugMenuRoot.Label, p Items: append(inkwell.DebugMenuRoot.Items, mySection), p }, 3The explorer cExplorerRoot0 is the other page tree the library ships, and the last page of cDebugMenuRoot0: the cGame0 itself, field by field, worked out by reflection when a page opens. A manager opens onto its entities, an entity onto its literal, an action onto the actions it is made of, a cText0 onto its translations — as deep as the game was written, and nothing is typed out twice. p EXPLORER p ├── Title · Width · Height · … value: the scalar itself p ├── SceneManager value: how many p │ └── value: its Name p │ ├── Title → en · hu value: the line, then each translati» p on p │ ├── Layers → → Asset · Z p │ └── Hotspots → p │ └── OnLook value: Seq p │ ├── run Enter queues the action p │ └── children → [0] value: Say p │ ├── run p │ ├── speaker value: paul p │ └── text value: the line p ├── ItemManager · CharacterManager · … p └── State · Inventory · … the run as it stands • The value column is what a row is worth right now: a name for what has one, the current language's line for a cText0, cGiven = true0 for a cCondition evaluated against the game as it stands, the kind of action — cSay0, cSeq0, cIf0 — for an cAction0, c#RRGGBBAA0 for a colour, a count for a collection. • A pointer to a registered entity is a link: cItem = key0 opens onto the item itself, wherever it was met. A function is cfunc0 and opens onto nothing; a cReply0, a cCustom0 and a cDerived0 are closures, and the explorer stops where the writing turns into code. • Unexported fields are read too, through a fresh pointer to the same memory: an action's speaker and line live in unexported fields of an unexported type, and an explorer that could not see inside a cSay0 would stop exactly where the writing starts. Every action row opens with crun0, so any authored sequence can be played from where it is written. • cExplore(game)0 runs a game with nothing on screen but this — a Stage that is a cSticky0 cMenu0 over the whole window, rooted at cExplorerRoot0 — validated and wired exactly as cRun0 wires it, so what it shows is the game as it would have run. r────────────────────────────────────────────────────────────────────────────── 2Themes 2────── p // theme.def.go p p type Theme struct { p Name string p p PanelBG color.Color p StatusText color.Color p FlashText color.Color p p VerbButtonBG color.Color p VerbButtonSelectedBG color.Color p VerbButtonText color.Color p p InventorySlotBG color.Color p InventorySlotSelectedBG color.Color p p SpeechBubbleBG color.Color p SpeechDefaultText color.Color p p DialogBG color.Color p DialogBorder color.Color p DialogChoiceBG color.Color p DialogChoiceHover color.Color p DialogSpeaker color.Color p DialogText color.Color p p EndCardBG color.Color p EndCardText color.Color p CursorColor color.Color p HotspotOutline color.Color p SceneBackdrop color.Color p p TopBarBG color.Color p TopBarText color.Color p TopBarAccent color.Color p p ChatLogBG color.Color p ChatLogPrompt color.Color p ChatLogResponse color.Color p ChatLogSystem color.Color p p CharacterPanelBG color.Color p CharacterPanelBorder color.Color p CharacterPanelTitle color.Color p } cNew0 registers the presets and selects cStartTheme0, or cclassic-scumm0 when the game names none. It adds rather than replaces, so a domain that hands over its own cThemeManager0 keeps everything already in it: t┌────────────────┬────────────────────────────────────────────────────────┐ t│h Name t│h Mood t│ t├────────────────┼────────────────────────────────────────────────────────┤ t│0 cclassic-scumm0 t│0 SCUMM-era dark blue with warm amber accents (default). t│ t│0 csierra-coin0 t│0 Darker purple and orange, made for the verb coin. t│ t│0 cpaper-notebook0 t│0 Cream paper, ink, a little sepia. t│ t│0 cterminal-green0 t│0 Retro phosphor green on black. t│ t└────────────────┴────────────────────────────────────────────────────────┘ Your own theme is just another registration: p g.ThemeManager.Register(inkwell.Theme{ p Name: "midnight-noir", p PanelBG: color.RGBA{6, 6, 12, 255}, p StatusText: color.White, p // … the remaining color fields … p }) p g.UseTheme("midnight-noir") Widgets read the active theme every frame, so a runtime switch shows up on the next draw. r────────────────────────────────────────────────────────────────────────────── 2Asset pipeline 2────────────── cAssetManager.Register0 stores only the spec. The first image request: 1. open cAsset.Path0, 2. on a missing or unreadable file, or a nil asset, generate a deterministic placeholder color from the asset's name and mark the entry, 3. cache the decoded c*ebiten.Image0. p // internal p func (la *loadedAssets) image(asset *Asset) *ebiten.Image p func (la *loadedAssets) isPlaceholder(asset *Asset) bool Widgets use cisPlaceholder0 to decide between the stylised placeholder and the real sprite. Image formats: PNG and JPEG. Sound is not an cAsset0 at all: a cVoice0, a cMusic0 or a cSound0 is decoded on demand by cAudioPlayer0. The engine's own font is not an asset at all: it is the atlas baked into cfont.png0. r────────────────────────────────────────────────────────────────────────────── 2Audio 2───── p // audio.def.go p p type Audible interface{ Source() string } p p type Voice struct{ Path string } // a recorded line p type Music struct{ Name, Path string; Loop bool } // a track p type Sound struct{ Name, Path string } // a noise p p // asset.audio.go p p func NewAudioPlayer() *AudioPlayer p func (a *AudioPlayer) PlayMusic(music *Music) p func (a *AudioPlayer) StopMusic() p func (a *AudioPlayer) PlayVoice(voice *Voice) float64 // seconds it lasts;» p 0 if nothing played p func (a *AudioPlayer) StopVoice() p func (a *AudioPlayer) PlaySound(sound *Sound) Three things are heard, and they are three types because they are played three ways: • cVoice0 is a recorded line. It sits on a cTranslation0, because the English and the Hungarian recording are two files, and cSay0 plays the one the resolved language carries — for exactly as long as the recording lasts, in place of the text-length timer. A skipped line stops talking. A cVoice0 is owned by one line, so it needs no name and no registration: c{Lang: "hu", Text: "Katt.", cVoice: &Voice{Path: "assets/voice/alley/click.hu.ogg"}}0. The player switches voices off in the settings (cSettings.Voice0, cSetVoice0), and cSay0 falls back to the timer. • cMusic0 is a track a cScene0 or a cStage0 plays while it shows, looping when cLoop says so. Entering a Scene or Stage whose cMusic0 is the one already playing changes nothing; a nil cMusic0 keeps whatever plays. cPlayMusic0 is the same thing as an action. • cSound0 is a noise — a door, a latch, a cassette clicking home — played once by cPlaySound0, over the music and over another cSound0. Backed by Ebiten's caudio0. The shared context is created lazily at 48 kHz on the first play; an existing caudio.CurrentContext0 (e.g. from a host process) is reused. Music streams from disk; voices and sounds are decoded once into PCM, cached by cSource0, and replayed with cNewPlayerFromBytes0. Finished sound players are pruned on the next cPlaySound0. Formats by extension: WAV, OGG Vorbis, MP3. Anything else fails to decode and the source 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. r────────────────────────────────────────────────────────────────────────────── 2Input 2───── p // input.def.go p p func (i *Input) Pos() (int, int) p func (i *Input) Point() Point p func (i *Input) LeftClicked() bool // just-pressed AND not consumed p func (i *Input) RightClicked() bool p func (i *Input) ConsumeLeft() p func (i *Input) ConsumeRight() p p func (i *Input) Wheel() (float64, float64) // this frame's travel; n» p othing consumes it p p func (i *Input) KeyPressed(key ebiten.Key) bool // just-pressed AND not c» p onsumed p func (i *Input) ConsumeKey(key ebiten.Key) p func (i *Input) ClaimKey(key ebiten.Key) bool // KeyPressed + ConsumeKe» p y The engine polls once per frame and snapshots the cursor, both just-pressed states, the wheel's travel and every key that went down. Consume-on-use: one click is authoritative, whoever consumes first wins. Keys work the same way, and cClaimKey0 is the shape a widget that binds half a dozen of them wants: a menu walking a list with the arrow keys claims them, so nothing else reads them on that frame. cKeyPressed0 is edge-triggered like cLeftClicked0 — holding a key answers true once — and a widget that wants a key held down reads cebiten.IsKeyPressed0 itself. r────────────────────────────────────────────────────────────────────────────── 2The game loop 2───────────── p // core.run.go p p func Run(g *Game) error // same as g.Run() cRun0 in order: 1. cg.Audio.attach(g.AssetManager)0 — wire the audio player to whichever asset registry the game is carrying by now. 2. cg.Validate()0. 3. cRegisterDefaultUI(g)0 if cWidgetManager0 is empty. 4. Place the start scene with no transition, bump cState.NoteVisit0, position actors, start music. 5. Queue cSeq(scene.OnEnter, OnStart script, OnFinale script)0 as the first action. 6. cebiten.SetWindowSize(Width*WindowScale, Height*WindowScale)0 (4 when unset), cebiten.SetWindowTitle(g.Title)0, cebiten.RunGame(&engine{g})0. p engine.Update: p poll input p update transition p if fading out -> return p p if scriptRunner != nil: p tick the Awake widgets, top layer down # a menu, so it stays clo» p sable p tick runner; tick characters; return p p clear hoverLabel p for w in WidgetManager, top layer down, WidgetVisible only: w.Tick(uictx» p ) p handleSceneInput() # hotspots, right-click re» p set p tick characters p p engine.Draw: p fill Theme.SceneBackdrop, or black p for layer in scene layers with Z <= 0, by Z: drawFullScreen(layer) p for c in characters sorted by Y: drawCharacter(c) p for layer in scene layers with Z > 0, by Z: drawFullScreen(layer) p for w in WidgetManager (by layer, then registration order), WidgetVisibl» p e only: w.Draw(screen, uictx) p draw transition overlay chandleSceneInput0 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 cselectedVerb0 to c"look"0. 3. Left-click with an item: chotspot.OnUseWith[item]0, then citem.OnUseWith[hotspot]0, then cGame.UseWithFail0, else flash "Nem ehhez.". 4. Left-click without: chotspot.handler(selectedVerb)0, then the verb's cDefault0, else flash "Semmi említésre méltó.". 5. Every successful click pushes a cLogAction0 line. Character movement: ctickCharacters0 steps the head of the waypoint queue at cSpeed0 px/sec, pops it on arrival, and the character goes idle when the queue empties. The queue is built by cWalk0, which returns cStatusRunning0 until the character stops. ctickPose0 picks the cPose0 each frame — walking, talking, waiting, idle, or the one cSetPose0 holds — and blits that sprite's current frame, mirrored to the facing, or falls back to the placeholder. r────────────────────────────────────────────────────────────────────────────── 2Scene transitions 2───────────────── p // transition.def.go p p type Transition interface { p Seconds() float64 p Draw(dst *ebiten.Image, width, height int, cover float64) p } p p func Fade(seconds float64) Transition p func FadeTo(seconds float64, to color.Color) Transition A cTransition0 is how one picture gives way to another, in two halves: the old picture is covered, the swap lands on the frame it is fully hidden, and the new picture is uncovered. cSeconds0 is one half; cDraw0 paints the cover for ccover running 0 → 1 and back. cFade0 is the one the engine ships, and a wipe or an iris is another implementation of the same two methods. A Stage names its own in cStage.Transition0; a scene change uses cFade(0.25)0. cGame.changeScene(name)0 starts that quarter-second fade. At the midpoint: 1. queue cprevScene.OnLeave0, 2. set the new ccurrentScene0, 3. cState.NoteVisit(name)0, 4. place actors at their cSceneActor.At0, 5. play the new cMusic0, 6. queue cnewScene.OnEnter0. Then the fade back in. Input is frozen during the fade-out half. cStartScene0 is set bwithout0 a transition, so the intro script runs at once. r────────────────────────────────────────────────────────────────────────────── 2Validation 2────────── p func (g *Game) Validate() error Called by cRun0 — after every cScene.Derived0 has been resolved, so a derived exit is checked like an authored one — and usable from tests. It checks that: • cStartScene0 and cStartStage0 are set, and registered, • a cStage.Hold0 has a cNext0 to move on to, and that cNext0 is registered, • every cSceneActor.Character0 and every cExit.To0 is registered (a nil cTo0 is allowed — it means "back the way you came"), • cPlayer0, when set, is a registered character, • an active theme is selected and registered. Most of what this used to check is now a type: a scene, an item or a dialog is referred to by pointer, so a name nobody registered is a compile error rather than one of these. It returns the first error, wrapping an cErr…0 sentinel for cerrors.Is0. Duplicate names are caught earlier, by the panic in cRegister0. r────────────────────────────────────────────────────────────────────────────── 2Save and load 2───────────── p // state.save.go p p func (g *Game) Save(slot int) error p func (g *Game) Load(slot int) error p func (g *Game) Slots(count int) []SlotInfo p p type SlotInfo struct { p Slot int p Exists bool p Scene string p SavedAt time.Time p } cSlots0 reads the header of every slot from 1 to ccount0, which is what a load or save screen lists. A missing or unreadable slot comes back as one that does not exist — a screen listing saves is not the place to report a parse error — so a game showing four slots gets four rows, three of which say "empty". The write time is in the file (csaved_at0, schema version 2), so a load screen can say when. p // inkwell/state.store.go p p type Store interface { p Read(key string) ([]byte, error) // ErrNotStored for a key nothing has» p written p Write(key string, data []byte) error p } p p func NewFileStore(app string) *FileStore // native: os.UserConfigDir()//.json p func NewMemoryStore() *MemoryStore // a map, for a test p p // inkwell/state.settings.go p p type Settings struct { p Version int p Lang string p Theme string p WindowScale int p Voice bool // recorded lines on or off; on until switc» p hed p Extra map[string]any // the game's own, kept untouched p } p p func (g *Game) Settings() *Settings p func (g *Game) SaveSettings() error bWhere it all goes.0 A cGame0 keeps what has to outlive the process through its cStore0: two documents, csettings0 and cslots0. cGame.App0 names the game to the machine, and the platform picks the place. Natively that is a directory under the user's configuration directory — c~/Library/Application Support/0 on macOS, c~/.config/0 on Linux, c%AppData%\0 on Windows — with one JSON file per document, written to a temporary file and renamed into place so a crash mid-write leaves the previous document. In a browser (cGOOS=js0) it is clocalStorage0, one entry per document, prefixed with the app name. A game that wants neither hands in its own cStore0; a test hands in a cMemoryStore0. bSettings.0 cRun0 reads the csettings0 document before the first frame: the stored language is used when the run has not chosen one (cinkwell.Lang0 left empty), the stored theme when it is registered, the stored window scale when the game named none — so a c-lang0 flag wins for this run without being written back. cUseLang0 and cUseTheme0 call cSaveSettings0, so a choice on a settings screen is kept the moment it is made. cExtra0 is the game's: read and write it through cSettings()0, call cSaveSettings0, and it round-trips beside the engine's fields. bDocuments by name.0 The two keys are exported as cSettingsKey0 and cSlotsKey0, so a game can name the documents it sends somewhere other than the default store — csteam.Steam{Cloud: []string{inkwell.SlotsKey}}0 keeps the saves in Steam Cloud and the settings on the machine — without knowing their spelling. cGame.AppName()0 is cApp0 or c"inkwell"0, the directory the default store files them under, for a cStore0 an extension builds beside it. p {"version": 1, "lang": "hu", "theme": "realworld-93", "window_scale": 2, "vo» p ice": true} bSlots.0 Every save slot lives in the one cslots0 document, keyed cslot0. What a load screen lists — when, where — sits in the clear beside the session, which is the save's JSON as a base64 string, so listing four slots decodes no session and the document reads as a table of slots rather than as four games. The session inside keeps its own schema version (2) independent of the document's (1). p { p "version": 1, p "slots": { p "slot1": {"saved_at": "…", "scene": "alley", "title": "Underwater Sun Ri» p sing", "state": "eyJ2ZXJzaW9uIjoy…"} p } p } Saved: current and previous scene, active verb, active theme, every character's position/target/moving flag, the whole cState0 (flags, vars, visited, talked), and the inventory with its selected slot. Static registrations are bnot0 saved — cdomain.Build()0 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 c*Game0. Not saved: • the in-flight cRunner0 and any active dialog — cLoad0 cancels both, so a save is effectively taken at an idle boundary; • the pose and its frame clock — the next frame re-derives the pose; the facing is saved; • walkbox path queues — a still-moving character gets a one-step straight-line path from cpos0 to ctarget0. cVar0 values go through cencoding/json0 as-is: numbers come back as cfloat640, strings as strings. The format is versioned, so a file from a newer version errors instead of silently losing fields. r────────────────────────────────────────────────────────────────────────────── 2Errors 2────── p // core.errors.go p p var ( p ErrUnknownAsset = errors.New("inkwell: unknown asset") p ErrUnknownScene = errors.New("inkwell: unknown scene") p ErrUnknownCharacter = errors.New("inkwell: unknown character") p ErrUnknownScript = errors.New("inkwell: unknown script") p ErrNoStartScene = errors.New("inkwell: StartScene not set or unknown» p scene") p ErrUnknownStage = errors.New("inkwell: unknown stage") p ErrNoStartStage = errors.New("inkwell: StartStage not set or unknown» p stage") p ErrNotStored = errors.New("inkwell: nothing stored under this key» p ") p ) cRegister0 and cMustGet0 panic instead of returning these: they signal construction-time bugs that should crash loudly. Every sentinel in the list is one cGame.Validate0 returns — an error the library never returns is not a sentinel, it is a name to keep in step. r────────────────────────────────────────────────────────────────────────────── 2Testing 2─────── 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. The library's own CI (c.woodpecker.yaml0) is one ctest0 step: cgo build ./...0 and cgo vet ./...0. It runs in cgit.teletypegames.org/build/ebitengine-ttg0 — the Ebitengine builder, needed for the cgo X11/GL/ALSA headers, with the Go modules of every TTG Go project preloaded so the step downloads nothing — pulled on every run, since the tag moves. p func TestBuildValidates(t *testing.T) { p g := domain.Build() p if err := g.Validate(); err != nil { p t.Fatalf("validate: %v", err) p } p } Debug switches: p inkwell.DebugLog = true // script queue, audio, scene change» p -> stderr p &inkwell.HotspotDebug{Enabled: true} // start with the F1 overlay on r────────────────────────────────────────────────────────────────────────────── 2Project layout 2────────────── All sources sit at the repo root, named ccategory.identifier.go0, so cls widget.* or cls action.*0 groups a category — and the category is the word the entity itself uses: ccharacter.*0 holds cCharacter0, cscript.*0 holds cScript0, cdialog.* holds cDialog0. There is no cui.*0 group: “UI” is not an entity anybody registers, and a widget, a theme and a verb are three categories that happen to be drawn. Below the category file that holds a group's shared contract, each file holds bone0 thing: one action, one condition, one preset theme, one widget. Adding a built-in action is adding a file, so the runs of them are written below as a glob rather than listed out. p inkwell/ # module git.teletypegames.org/engines/inkwe» p ll p ├── core.doc.go # package docs p ├── core.manager.go # Manager[T Named], Named, TypeLabel p ├── core.game.go # Game aggregate, its managers and accessors p ├── core.transition.go # the one Transition in flight, scene or Sta» p ge p ├── core.validate.go # Game.Validate p ├── core.log.go # LogKind, LogMessage, the chat-log buffer p ├── core.engine.go # ebiten.Game adapter (Update/Draw/Layout) p ├── core.run.go # Run() p ├── core.errors.go # sentinel errors p │ p ├── util.geometry.go # Point, Rectangle, Polygon, Shape p ├── util.log.go # DebugLog + logf p │ p ├── asset.def.go # Asset: a picture p ├── audio.def.go # Audible: Voice, Music, Sound p ├── asset.manager.go # AssetManager alias + lazy loader p ├── asset.audio.go # AudioPlayer (WAV/OGG/MP3): music, voices, » p sounds p ├── asset.text.go # drawText / wrapText helpers p ├── asset.fs.go # AssetFS: an embed.FS or the OS filesystem p │ p ├── i18n.text.go # Translation, Text, Format, Lang p ├── i18n.strings.go # the engine's own wording, per language p ├── i18n.font.go # the glyph atlas and its lookup p ├── i18n.glyphs.go # generated: the atlas coverage p ├── font.png # generated: the atlas itself p ├── internal/fontgen/ # the generator (its own module) p │ p ├── scene.def.go # Scene, SceneActor p ├── scene.manager.go # SceneManager alias p ├── scene.hotspot.go # Hotspot, CursorKind p ├── scene.exit.go # Exit, ExitSide + edge-strip geometry p ├── scene.trigger.go # Trigger + rising-edge engine sweep p ├── scene.path.go # walkbox routing (BFS over polygon adjacenc» p y) p ├── scene.change.go # changeScene: the fade, and what happens mi» p d-fade p │ p ├── item.def.go # Item p ├── item.manager.go # ItemManager alias p ├── item.inventory.go # Inventory p │ p ├── character.def.go # Character p ├── character.manager.go # CharacterManager alias p ├── character.pose.go # tickPose: which Pose, which frame p ├── sprite.def.go # Sprite, Pose, Facing, drawSprite p ├── character.runtime.go # runtimeChar: placing, walking, ticking the» p cast p ├── speaker.def.go # Speaker: a voice with no body, and how a n» p ame resolves to one p ├── speaker.manager.go # SpeakerManager alias p ├── character.draw.go # the cast on screen, placeholder shapes inc» p luded p │ p ├── dialog.def.go # Dialog, DialogNode, DialogChoice p ├── dialog.manager.go # DialogManager alias p ├── dialog.runtime.go # the conversation in flight p │ p ├── action.def.go # Action, Runner, Ctx, Status: the contract p ├── action.queue.go # Game.Do and the one-at-a-time queue p ├── action.custom.go # Custom p ├── action.*.go # one built-in action per file: say, goto, w» p ait, walk, … p │ p ├── condition.def.go # Condition p ├── condition.*.go # one condition per file: flag, has_item, an» p d, or, … p │ p ├── script.def.go # Script p ├── script.manager.go # ScriptManager alias p │ p ├── state.def.go # State (flags, vars, visited, talked) p ├── state.store.go # Store, MemoryStore: where settings and sav» p es live p ├── state.store_native.go # FileStore under os.UserConfigDir (!js) p ├── state.store_js.go # LocalStore over window.localStorage (js) p ├── state.settings.go # Settings: lang, theme, window scale, the g» p ame's Extra p ├── state.save.go # Save/Load: the slots document, one base64 » p session per slot p │ p ├── input.def.go # Input (consume-on-use) p │ p ├── theme.def.go # Theme p ├── theme.manager.go # ThemeManager alias p ├── theme.presets.go # RegisterPresetThemes p ├── theme.*.go # one preset theme per file p │ p ├── verb.def.go # Verb p ├── verb.manager.go # VerbManager alias p ├── verb.defaults.go # the built-in SCUMM verb set p │ p ├── widget.def.go # Widget interface, Layer, Gated, Awake, UIC» p tx, Size, Align p ├── widget.manager.go # WidgetManager alias + layer-ordered iterat» p ors p ├── widget.preset_scumm.go # RegisterDefaultUI p ├── widget.preset_radial.go # RegisterRadialVerbUI p ├── widget.preset_rich.go # RegisterRichUI p ├── widget.verb_bar.go # VerbBar p ├── widget.verb_radial.go # RadialVerbs p ├── widget.inventory.go # InventoryBar p ├── widget.status.go # StatusLine p ├── widget.speech.go # SpeechBubble p ├── widget.dialog_box.go # DialogBox p ├── widget.end_card.go # EndCard p ├── widget.cursor.go # Cursor p ├── widget.hotspot_debug.go # HotspotDebug p ├── widget.menu.go # Menu + MenuItem: the menu system p ├── widget.debug_menu.go # DebugMenuRoot: the menu system's one built» p -in use p ├── widget.panel.go # Panel p ├── widget.top_bar.go # TopBar p ├── widget.character_panel.go # CharacterPanel + CharStat p ├── widget.chat_log.go # ChatLog + SpeakerStyle p ├── widget.letterbox.go # Letterbox p ├── widget.key_binding.go # KeyBinding p ├── widget.text_card.go # TextCard p ├── stage.def.go # Stage p ├── stage.manager.go # StageManager p │ p ├── achievement.def.go # Achievement → alias to achievement/ p ├── achievement.manager.go # AchievementManager alias p ├── achievement.runtime.go # tickAchievements: the rising edge, handed » p to the Unlockers p ├── extension.def.go # Extension, Unlocker; start, tick, stop p ├── achievement/ # Achievement and its Manager p ├── steam/ # the Steamworks Extension: init, callbacks,» p achievements, cloud saves p │ p ├── transition.def.go # Transition p ├── transition.fade.go # Fade, FadeTo p ├── widget.hotspot_panel.go # HotspotPanel p │ p ├── LICENSE.md # MIT p └── README.md MIT licensed. Copyright © 2026 Teletype Games.