main.go

// Command roguelike is a tile-based dungeon crawler on gophics, and the driver
// example for paint.DrawSprite: the whole map, monsters, and items are blitted
// from one procedurally-generated atlas texture (no binary assets). Turn-based,
// with a minimal d20 combat core. Arrow keys or tap to move; bump to attack;
// reach the stairs to descend.
//
//	go run ./examples/roguelike
package main

import (
	"log"
	"time"

	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/font/gofont/goregular"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/sound"
	"github.com/doug/gophics/sound/device"
)

func main() {
	// Audio is best-effort: if the device won't open, the game runs silent.
	mixer := sound.NewMixer()
	if closer, err := device.Open(mixer); err != nil {
		log.Printf("audio disabled: %v", err)
	} else {
		defer closer.Close()
	}

	err := app.Run(Roguelike{Seed: time.Now().UnixNano(), Sound: mixer}, app.Config{
		Title:        "Roguelike",
		Size:         geom.Size{W: 900, H: 680},
		Background:   colBG,
		Font:         goregular.TTF,
		FontFamilies: map[string][]byte{"bold": gobold.TTF},
	})
	if err != nil {
		log.Fatal(err)
	}
}

dungeon.go

package main

import (
	"math/rand"
	"slices"
)

// Cell is a map tile's terrain.
type Cell uint8

const (
	CellWall Cell = iota
	CellFloor
	CellDoor
	CellStairs
)

// Room is an axis-aligned rectangle of floor.
type Room struct{ X, Y, W, H int }

func (r Room) center() (int, int) { return r.X + r.W/2, r.Y + r.H/2 }
func (r Room) overlaps(o Room) bool {
	return r.X <= o.X+o.W && r.X+r.W >= o.X && r.Y <= o.Y+o.H && r.Y+r.H >= o.Y
}

// Dungeon is a grid of cells with the rooms that were carved.
type Dungeon struct {
	W, H  int
	cells []Cell
	rooms []Room
}

func (d *Dungeon) at(x, y int) Cell {
	if x < 0 || y < 0 || x >= d.W || y >= d.H {
		return CellWall
	}
	return d.cells[y*d.W+x]
}

func (d *Dungeon) set(x, y int, c Cell) {
	if x >= 0 && y >= 0 && x < d.W && y < d.H {
		d.cells[y*d.W+x] = c
	}
}

// walkable reports whether an entity can stand on (x, y).
func (d *Dungeon) walkable(x, y int) bool {
	c := d.at(x, y)
	return c == CellFloor || c == CellDoor || c == CellStairs
}

// opaque reports whether (x, y) blocks line of sight.
func (d *Dungeon) opaque(x, y int) bool { return d.at(x, y) == CellWall }

// genDungeon carves up to maxRooms non-overlapping rooms and connects each to
// the previous with an L-shaped corridor. The last room gets the stairs down.
func genDungeon(w, h, maxRooms int, rng *rand.Rand) *Dungeon {
	d := &Dungeon{W: w, H: h, cells: make([]Cell, w*h)} // all walls
	for range maxRooms {
		rw, rh := 4+rng.Intn(7), 3+rng.Intn(5)
		rx, ry := 1+rng.Intn(w-rw-2), 1+rng.Intn(h-rh-2)
		room := Room{rx, ry, rw, rh}
		clash := slices.ContainsFunc(d.rooms, room.overlaps)
		if clash {
			continue
		}
		for y := ry; y < ry+rh; y++ {
			for x := rx; x < rx+rw; x++ {
				d.set(x, y, CellFloor)
			}
		}
		if len(d.rooms) > 0 {
			px, py := d.rooms[len(d.rooms)-1].center()
			cx, cy := room.center()
			d.carveCorridor(px, py, cx, cy, rng)
		}
		d.rooms = append(d.rooms, room)
	}
	if n := len(d.rooms); n > 0 {
		sx, sy := d.rooms[n-1].center()
		d.set(sx, sy, CellStairs)
	}
	return d
}

func (d *Dungeon) carveCorridor(x0, y0, x1, y1 int, rng *rand.Rand) {
	if rng.Intn(2) == 0 {
		d.hLine(x0, x1, y0)
		d.vLine(y0, y1, x1)
	} else {
		d.vLine(y0, y1, x0)
		d.hLine(x0, x1, y1)
	}
}

func (d *Dungeon) hLine(x0, x1, y int) {
	if x0 > x1 {
		x0, x1 = x1, x0
	}
	for x := x0; x <= x1; x++ {
		if d.at(x, y) == CellWall {
			d.set(x, y, CellFloor)
		}
	}
}

func (d *Dungeon) vLine(y0, y1, x int) {
	if y0 > y1 {
		y0, y1 = y1, y0
	}
	for y := y0; y <= y1; y++ {
		if d.at(x, y) == CellWall {
			d.set(x, y, CellFloor)
		}
	}
}

game.go

package main

import (
	"fmt"
	"math/rand"
	"strings"
)

// Entity is the player or a monster.
type Entity struct {
	X, Y            int
	Tile            TileID
	Name            string
	HP, MaxHP       int
	Atk, AC, Damage int // d20 attack bonus, armor class, damage die
	Alive           bool
	FlipX           bool

	// Speed is how many turns this entity takes per player turn, as a
	// numerator over 2: 1 is half speed, 2 is normal, 3 gives an extra turn
	// every other round. Differing speeds are what stop every monster from
	// being the same fight with different numbers — a rat you can outrun is a
	// different problem from a brute you cannot.
	Speed  int
	energy int

	// Asleep monsters ignore the player until one comes close, so a room is
	// something you enter carefully rather than a queue of things already
	// walking at you.
	Asleep bool

	XP int // awarded to the player on kill
}

// Item is a pickup lying on the floor.
type Item struct {
	X, Y   int
	Tile   TileID
	Gold   int  // >0 → gold; else a potion
	Amulet bool // the win goal
}

// maxDepth is where the Amulet of Yendor waits.
const maxDepth = 5

// SoundID names a sound effect; the widget maps these to samples. The engine
// only emits ids, staying decoupled from the audio package (and silent in tests).
type SoundID int

const (
	SndHit SoundID = iota
	SndCoin
	SndPotion
	SndDescend
	SndDie
	SndWin
)

// Game is the full, rendering-free game state.
type Game struct {
	d        *Dungeon
	player   *Entity
	monsters []*Entity
	items    []*Item
	seen     []bool // ever revealed (fog memory)
	visible  []bool // in the current field of view
	rng      *rand.Rand
	log      []string
	depth    int
	gold     int
	// potions are carried, not drunk where they lie: the decision of when to
	// spend one is the most interesting choice this game has, and picking them
	// up automatically threw it away.
	potions int
	level   int
	xp      int
	kills   int
	turns   int
	dead    bool
	won     bool
	sfx     func(id SoundID, pan float64) // optional; set by the widget
	// onHit reports a landed blow to the presentation layer. The engine stays
	// rendering-free: it says what happened, not what it should look like.
	onHit func(attacker, target *Entity, dmg int)
}

func (g *Game) play(id SoundID, pan float64) {
	if g.sfx != nil {
		g.sfx(id, pan)
	}
}

// panAt maps a world x to a stereo pan (-1..1) relative to the player.
func (g *Game) panAt(x int) float64 {
	p := float64(x-g.player.X) / fovRadius
	if p < -1 {
		p = -1
	} else if p > 1 {
		p = 1
	}
	return p
}

const fovRadius = 7

// newGame builds level 1 with a seeded RNG.
func newGame(seed int64) *Game {
	g := &Game{rng: rand.New(rand.NewSource(seed)), depth: 1}
	g.build()
	return g
}

// build lays out the current depth: dungeon, player, monsters, items, FOV.
func (g *Game) build() {
	d := genDungeon(48, 32, 14, g.rng)
	g.d = d
	g.seen = make([]bool, d.W*d.H)
	g.visible = make([]bool, d.W*d.H)
	g.monsters = nil
	g.items = nil

	px, py := d.rooms[0].center()
	if g.player == nil {
		g.player = &Entity{Tile: TPlayer, Name: "you", HP: 20, MaxHP: 20, Atk: 4, AC: 13, Damage: 6, Alive: true, Speed: 2}
		g.level, g.potions = 1, 1
	}
	g.player.X, g.player.Y = px, py

	// Populate the other rooms with monsters and loot, scaling with depth.
	for _, r := range d.rooms[1:] {
		// Deeper levels are denser as well as tougher, so descending feels
		// like a decision rather than a formality.
		for n := 0; n < 1+g.rng.Intn(1+g.depth/2); n++ {
			mx := r.X + 1 + g.rng.Intn(max(1, r.W-1))
			my := r.Y + 1 + g.rng.Intn(max(1, r.H-1))
			if !g.d.walkable(mx, my) || g.monsterAt(mx, my) != nil {
				continue
			}
			g.monsters = append(g.monsters, g.spawn(mx, my))
		}
		if g.rng.Intn(2) == 0 {
			ix, iy := r.X+1+g.rng.Intn(r.W-1), r.Y+1+g.rng.Intn(r.H-1)
			if g.rng.Intn(2) == 0 {
				g.items = append(g.items, &Item{X: ix, Y: iy, Tile: TGold, Gold: 3 + g.rng.Intn(12)})
			} else {
				g.items = append(g.items, &Item{X: ix, Y: iy, Tile: TPotion})
			}
		}
	}
	// The Amulet of Yendor waits on the deepest level (no stairs down there).
	if g.depth >= maxDepth {
		sx, sy := d.rooms[len(d.rooms)-1].center()
		d.set(sx, sy, CellFloor)
		g.items = append(g.items, &Item{X: sx, Y: sy, Tile: TAmulet, Amulet: true})
	}

	g.computeFOV()
	if g.depth >= maxDepth {
		g.logf("Level %d — the Amulet of Yendor is near!", g.depth)
	} else {
		g.logf("You enter dungeon level %d.", g.depth)
	}
}

// spawn picks a monster for the current depth. The mix shifts down: rats
// thin out, brutes appear from level three, so the same tactics stop working.
func (g *Game) spawn(x, y int) *Entity {
	d := g.depth
	roll := g.rng.Intn(100)
	switch {
	case roll < max(10, 45-d*8):
		// Rat: fragile and fast. It reaches you first and softens you up.
		return &Entity{X: x, Y: y, Tile: TRat, Name: "rat", Alive: true, Asleep: true,
			HP: 3 + d/2, MaxHP: 3 + d/2, Atk: 2, AC: 10, Damage: 3, Speed: 3, XP: 3}
	case roll < 80 || d < 3:
		// Goblin: the baseline fight.
		return &Entity{X: x, Y: y, Tile: TGoblin, Name: "goblin", Alive: true, Asleep: true,
			HP: 7 + d, MaxHP: 7 + d, Atk: 3 + d/2, AC: 12, Damage: 5, Speed: 2, XP: 8}
	default:
		// Brute: slow, armoured, hits hard. You can outwalk it — the question
		// is whether the room lets you.
		return &Entity{X: x, Y: y, Tile: TGoblin, Name: "brute", Alive: true, Asleep: true,
			HP: 14 + d*3, MaxHP: 14 + d*3, Atk: 4 + d/2, AC: 14, Damage: 8, Speed: 1, XP: 20}
	}
}

// xpToLevel is the total experience needed for the next level.
func xpToLevel(level int) int { return 12 * level * level }

// gainXP awards experience and levels the player up, which is what makes
// fighting worth the risk instead of something to be walked around.
func (g *Game) gainXP(n int) {
	g.xp += n
	for g.xp >= xpToLevel(g.level) {
		g.xp -= xpToLevel(g.level)
		g.level++
		g.player.MaxHP += 4
		g.player.HP = g.player.MaxHP
		g.player.Atk++
		if g.level%2 == 0 {
			g.player.Damage++
		}
		g.logf("You reach level %d — you feel stronger.", g.level)
		g.play(SndWin, 0)
	}
}

func (g *Game) logf(format string, a ...any) {
	g.log = append(g.log, fmt.Sprintf(format, a...))
	if len(g.log) > 6 {
		g.log = g.log[len(g.log)-6:]
	}
}

// monsterAt returns the living monster on (x, y), if any.
func (g *Game) monsterAt(x, y int) *Entity {
	for _, m := range g.monsters {
		if m.Alive && m.X == x && m.Y == y {
			return m
		}
	}
	return nil
}

// Move attempts to move the player by (dx, dy): attack a monster there, step
// onto walkable floor (picking up items, descending stairs), else do nothing.
// A successful action passes the turn to the monsters.
func (g *Game) Move(dx, dy int) {
	if g.dead || g.won || (dx == 0 && dy == 0) {
		return
	}
	if dx < 0 {
		g.player.FlipX = true
	} else if dx > 0 {
		g.player.FlipX = false
	}
	nx, ny := g.player.X+dx, g.player.Y+dy
	if m := g.monsterAt(nx, ny); m != nil {
		g.attack(g.player, m)
		g.endTurn()
		return
	}
	if !g.d.walkable(nx, ny) {
		return
	}
	g.player.X, g.player.Y = nx, ny
	g.pickup()
	if g.d.at(nx, ny) == CellStairs {
		g.descend()
		return
	}
	g.endTurn()
}

func (g *Game) pickup() {
	kept := g.items[:0]
	for _, it := range g.items {
		if it.X == g.player.X && it.Y == g.player.Y {
			if it.Amulet {
				g.won = true
				g.play(SndWin, 0)
				g.logf("You claim the Amulet of Yendor — you win!")
				continue
			}
			if it.Gold > 0 {
				g.gold += it.Gold
				g.play(SndCoin, 0)
				g.logf("You pick up %d gold.", it.Gold)
			} else {
				g.potions++
				g.play(SndCoin, 0)
				g.logf("You pocket a potion (%d held).", g.potions)
			}
			continue
		}
		kept = append(kept, it)
	}
	g.items = kept
}

// Quaff drinks a held potion. It is a turn like any other, so healing in
// front of something that is still swinging costs you a hit — which is the
// point: the interesting question is when, not whether.
func (g *Game) Quaff() {
	if g.dead || g.won {
		return
	}
	if g.potions == 0 {
		g.logf("You have no potions.")
		return
	}
	if g.player.HP >= g.player.MaxHP {
		g.logf("You are unhurt.")
		return
	}
	g.potions--
	heal := 8 + g.level*2
	before := g.player.HP
	g.player.HP = min(g.player.MaxHP, g.player.HP+heal)
	g.play(SndPotion, 0)
	g.logf("You quaff a potion (+%d HP).", g.player.HP-before)
	g.endTurn()
}

// Wait passes a turn. Standing in a doorway so only one thing can reach you
// is a real tactic, and it needs a way to spend a turn without moving.
func (g *Game) Wait() {
	if g.dead || g.won {
		return
	}
	g.endTurn()
}

// endTurn runs the monsters and recomputes sight after any player action.
func (g *Game) endTurn() {
	g.turns++
	g.monstersAct()
	g.computeFOV()
}

func (g *Game) descend() {
	g.play(SndDescend, 0)
	g.depth++
	g.gold += 5
	g.build()
}

// attack resolves one d20 attack: d20 + Atk vs AC, then Damage die on a hit.
func (g *Game) attack(a, b *Entity) {
	roll := g.rng.Intn(20) + 1
	// Pan toward the non-player combatant.
	loc := b
	if b == g.player {
		loc = a
	}
	pan := g.panAt(loc.X)
	if roll+a.Atk >= b.AC {
		dmg := g.rng.Intn(b.hitDie(a)) + 1
		b.HP -= dmg
		if b.HP < 0 {
			b.HP = 0 // a corpse is at zero, not in debt
		}
		g.play(SndHit, pan)
		if g.onHit != nil {
			g.onHit(a, b, dmg)
		}
		g.logf("%s hit %s for %d.", cap1(a.Name), b.Name, dmg)
		if b.HP <= 0 {
			b.Alive = false
			if b == g.player {
				g.logf("You die.")
			} else {
				g.logf("%s dies.", cap1(b.Name))
			}
			if b == g.player {
				g.dead = true
				g.play(SndDie, 0)
				g.logf("You have died on level %d.", g.depth)
			} else {
				g.kills++
				g.gainXP(b.XP)
			}
		}
	} else {
		g.logf("%s missed %s.", cap1(a.Name), b.Name)
	}
}

func (e *Entity) hitDie(a *Entity) int {
	if a.Damage > 0 {
		return a.Damage
	}
	return 4
}

// monstersAct runs each living monster: attack if adjacent to the player, else
// step toward the player when it can see them.
func (g *Game) monstersAct() {
	for _, m := range g.monsters {
		if !m.Alive || g.dead {
			continue
		}
		// Wake on proximity rather than on sight, so creeping around the edge
		// of a room is a real option and a corridor is not a conga line.
		if m.Asleep {
			if g.visibleAt(m.X, m.Y) && dist(m.X, m.Y, g.player.X, g.player.Y) <= 4 {
				m.Asleep = false
				g.logf("The %s notices you.", m.Name)
			} else {
				continue
			}
		}
		// Energy accrues at the monster's speed against a cost of 2 per turn,
		// so a rat acts three times per two player turns and a brute once.
		sp := m.Speed
		if sp <= 0 {
			sp = 2
		}
		m.energy += sp
		for m.energy >= 2 && m.Alive && !g.dead {
			m.energy -= 2
			g.monsterStep(m)
		}
	}
}

// monsterStep is one monster action: swing if adjacent, else close in.
func (g *Game) monsterStep(m *Entity) {
	{
		dx, dy := g.player.X-m.X, g.player.Y-m.Y
		if abs(dx) <= 1 && abs(dy) <= 1 {
			g.attack(m, g.player)
			return
		}
		sx, sy := sign(dx), sign(dy)
		if g.step(m, sx, sy) || g.step(m, sx, 0) || g.step(m, 0, sy) {
			m.FlipX = sx < 0
		}
	}
}

// all returns the player and every living monster — the set the renderer has
// to track positions for.
func (g *Game) all() []*Entity {
	out := make([]*Entity, 0, len(g.monsters)+1)
	out = append(out, g.player)
	for _, m := range g.monsters {
		if m.Alive {
			out = append(out, m)
		}
	}
	return out
}

// dist is Chebyshev distance — the grid's own notion of "how many steps".
func dist(x0, y0, x1, y1 int) int { return max(abs(x1-x0), abs(y1-y0)) }

func (g *Game) step(m *Entity, dx, dy int) bool {
	if dx == 0 && dy == 0 {
		return false
	}
	nx, ny := m.X+dx, m.Y+dy
	if !g.d.walkable(nx, ny) || g.monsterAt(nx, ny) != nil || (nx == g.player.X && ny == g.player.Y) {
		return false
	}
	m.X, m.Y = nx, ny
	return true
}

func (g *Game) visibleAt(x, y int) bool {
	if x < 0 || y < 0 || x >= g.d.W || y >= g.d.H {
		return false
	}
	return g.visible[y*g.d.W+x]
}

func (g *Game) seenAt(x, y int) bool {
	if x < 0 || y < 0 || x >= g.d.W || y >= g.d.H {
		return false
	}
	return g.seen[y*g.d.W+x]
}

// computeFOV recomputes visibility: every cell within fovRadius with an
// unobstructed line from the player is visible (and remembered as seen).
func (g *Game) computeFOV() {
	for i := range g.visible {
		g.visible[i] = false
	}
	px, py := g.player.X, g.player.Y
	for y := py - fovRadius; y <= py+fovRadius; y++ {
		for x := px - fovRadius; x <= px+fovRadius; x++ {
			if x < 0 || y < 0 || x >= g.d.W || y >= g.d.H {
				continue
			}
			if (x-px)*(x-px)+(y-py)*(y-py) > fovRadius*fovRadius {
				continue
			}
			if g.los(px, py, x, y) {
				g.visible[y*g.d.W+x] = true
				g.seen[y*g.d.W+x] = true
			}
		}
	}
}

// los is a Bresenham line-of-sight test: true when no wall lies strictly between
// (x0,y0) and (x1,y1).
func (g *Game) los(x0, y0, x1, y1 int) bool {
	dx, dy := abs(x1-x0), abs(y1-y0)
	sx, sy := sign(x1-x0), sign(y1-y0)
	err := dx - dy
	x, y := x0, y0
	for {
		if x == x1 && y == y1 {
			return true
		}
		if !(x == x0 && y == y0) && g.d.opaque(x, y) {
			return false
		}
		e2 := 2 * err
		if e2 > -dy {
			err -= dy
			x += sx
		}
		if e2 < dx {
			err += dx
			y += sy
		}
	}
}

func abs(v int) int {
	if v < 0 {
		return -v
	}
	return v
}

func sign(v int) int {
	switch {
	case v > 0:
		return 1
	case v < 0:
		return -1
	}
	return 0
}

func cap1(s string) string {
	if s == "" {
		return s
	}
	return strings.ToUpper(s[:1]) + s[1:]
}

roguelike.go

package main

import (
	"fmt"
	"image"
	"math"
	"math/rand"
	"time"

	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/paint"
	"github.com/doug/gophics/shell"
	"github.com/doug/gophics/sound"
	"github.com/doug/gophics/sound/procedural"
	"github.com/doug/gophics/widget"
)

// Roguelike is the root widget: a tile dungeon crawler rendered entirely with
// paint.DrawSprite from one procedurally-generated atlas. Sound is optional
// (nil → silent, e.g. in tests).
type Roguelike struct {
	Seed  int64
	Sound *sound.Mixer
}

func (Roguelike) CreateState() widget.State { return &gameState{} }

// stateHook lets tests observe the mounted state.
var stateHook func(*gameState)

type gameState struct {
	widget.StateBase[Roguelike]
	ctx      widget.Ctx
	g        *Game
	atlas    *image.RGBA
	restarts int64

	snd     *sound.Mixer
	rng     *rand.Rand
	samples map[SoundID]*sound.Sample
	music   *sound.Voice

	origin geom.Pt // last camera origin (world px), for tap→cell mapping
	ts     float32 // last tile size on screen

	fx  effects
	tkr fxTicker

	// hpGhost trails the real HP fraction so a hit leaves a visible wound on
	// the bar for a moment instead of just being a shorter bar.
	hpGhost float32
}

// effects is everything that is purely presentational: where entities are
// drawn as opposed to where they are, and the short-lived flourishes that make
// a turn feel like it happened. The game logic never reads any of it.
type effects struct {
	clock  float64             // seconds since mount, for the torch flicker
	pos    map[*Entity]geom.Pt // render position, easing toward the real cell
	flash  map[*Entity]float32 // white hit flash, 1 → 0
	lunge  map[*Entity]geom.Pt // attack shove, decaying to zero
	floats []floater
	shake  float32
}

// floater is a damage number rising off a hit.
type floater struct {
	x, y float32 // world pixels at spawn
	text string
	col  paint.Color
	age  float32
}

// fxTicker advances the effects. It reports "still running" only while there
// is something to animate, so a game sitting still costs no frames.
type fxTicker struct{ s *gameState }

func (t *fxTicker) Tick(dt float64) bool {
	s := t.s
	s.fx.clock += dt
	busy := s.fx.advance(float32(dt), s.g, s.ts)
	if want := clamp01(float32(s.g.player.HP) / float32(s.g.player.MaxHP)); s.hpGhost > want {
		s.hpGhost -= float32(dt) * 0.6
		if s.hpGhost < want {
			s.hpGhost = want
		}
		busy = true
	} else {
		s.hpGhost = want
	}
	// The torch flickers forever, but only repaint for it while something is
	// on screen to see; a still frame does not need 60 fps of flicker.
	if busy {
		s.SetState(nil)
	}
	return busy
}

var (
	colBG     = paint.RGB(0.04, 0.045, 0.06)
	colPanel  = paint.Color{R: 0.08, G: 0.09, B: 0.12, A: 0.93}
	colInk    = paint.RGB(0.86, 0.88, 0.92)
	colDim    = paint.RGB(0.55, 0.58, 0.64)
	colHP     = paint.RGB(0.80, 0.27, 0.30)
	colHPbg   = paint.Color{R: 1, G: 1, B: 1, A: 0.12}
	colCoin   = paint.RGB(0.90, 0.74, 0.30)
	colBanner = paint.Color{R: 0, G: 0, B: 0, A: 0.62}
	colDamage = paint.RGB(1.00, 0.86, 0.55)
	colXP     = paint.RGB(0.44, 0.72, 0.92)
)

func (s *gameState) Init(ctx widget.Ctx) {
	s.ctx = ctx
	s.atlas = buildAtlas()
	s.fx.pos = map[*Entity]geom.Pt{}
	s.fx.flash = map[*Entity]float32{}
	s.fx.lunge = map[*Entity]geom.Pt{}
	s.tkr.s = s
	ctx.AddTicker(&s.tkr)
	s.rng = rand.New(rand.NewSource(1))
	s.snd = s.W().Sound
	if s.snd != nil {
		s.samples = map[SoundID]*sound.Sample{
			SndHit:     procedural.Hit(),
			SndCoin:    procedural.Coin(),
			SndPotion:  procedural.Blip(720, 0.14),
			SndDescend: procedural.Thud(),
			SndDie:     procedural.Blip(140, 0.4),
			SndWin:     procedural.Coin(),
		}
		s.music = s.snd.PlaySource(procedural.DungeonMusic(1),
			sound.PlayOptions{Volume: 0.30, FadeIn: 2 * time.Second}) // ambient loop, fades in
	}
	s.g = newGame(s.W().Seed)
	s.g.onHit = s.onHit
	s.attachSound()
	if stateHook != nil {
		stateHook(s)
	}
}

// Dispose stops the effects ticker so it cannot outlive the widget.
func (s *gameState) Dispose() { s.ctx.RemoveTicker(&s.tkr) }

// attachSound wires the current game's sound hook to the mixer (a no-op without
// audio). Called on mount and after each restart. Hits get a small random pitch
// for variety and a pan from the game (positional combat).
func (s *gameState) attachSound() {
	if s.snd == nil {
		return
	}
	s.g.sfx = func(id SoundID, pan float64) {
		smp := s.samples[id]
		if smp == nil {
			return
		}
		opts := sound.PlayOptions{Volume: 0.55, Pan: pan}
		if id == SndHit {
			opts.Pitch = 0.9 + s.rng.Float64()*0.35
		}
		s.snd.Play(smp, opts)
	}
}

func (s *gameState) Build(_ widget.Ctx) widget.Widget {
	return widget.Interactive{
		Gestures: widget.Gestures{
			OnKey: func(k shell.Key) {
				if k.Kind == shell.KeyPress {
					s.key(k.Code)
				}
			},
			OnPress: func(p geom.Pt) { s.tap(p) },
		},
		Child: widget.Canvas{Clip: true, Draw: s.draw},
	}
}

func (s *gameState) key(c shell.KeyCode) {
	// Restart is on any key once the run is over, so the switch below only has
	// to describe a live game.
	if s.g.dead || s.g.won {
		s.act(0, 0)
		return
	}
	switch c {
	case shell.KeyLeft, shell.KeyA:
		s.step(-1, 0)
	case shell.KeyRight, shell.KeyD:
		s.step(1, 0)
	case shell.KeyUp, shell.KeyW:
		s.step(0, -1)
	case shell.KeyDown, shell.KeyS:
		s.step(0, 1)
	case shell.KeyQ:
		s.g.Quaff()
		s.after()
	case shell.KeySpace:
		s.g.Wait()
		s.after()
	}
}

// step is a movement action.
func (s *gameState) step(dx, dy int) {
	s.g.Move(dx, dy)
	s.after()
}

// tap moves one step toward the tapped cell (touch/mouse control).
func (s *gameState) tap(p geom.Pt) {
	if s.ts == 0 {
		return
	}
	cx := int((p.X + s.origin.X) / s.ts)
	cy := int((p.Y + s.origin.Y) / s.ts)
	s.act(sign(cx-s.g.player.X), sign(cy-s.g.player.Y))
}

func (s *gameState) act(dx, dy int) {
	if s.g.dead || s.g.won {
		s.restarts++
		s.g = newGame(s.W().Seed + s.restarts) // any input after death/win starts anew
		s.g.onHit = s.onHit
		s.fx.pos = map[*Entity]geom.Pt{}
		s.fx.flash = map[*Entity]float32{}
		s.fx.lunge = map[*Entity]geom.Pt{}
		s.fx.floats = nil
		s.attachSound()
	} else {
		s.g.Move(dx, dy)
	}
	s.after()
}

// after runs the housekeeping every action shares.
func (s *gameState) after() {
	if s.music != nil {
		s.music.SetVolume(0.28 + 0.05*float64(s.g.depth-1)) // tenser as you descend
	}
	s.SetState(nil)
}

func (s *gameState) draw(c paint.Canvas, size geom.Size) {
	c.Clear(colBG)
	g := s.g
	ts := tileSize(size)
	s.ts = ts
	// Follow the player's eased position, not the cell, so the camera glides
	// with them instead of jumping a whole tile ahead of the sprite.
	pp := s.renderPos(g.player)
	ox := pp.X - size.W/2 + ts/2
	oy := pp.Y - (size.H-hudHeight)/2 + ts/2 // leave room for the HUD
	if s.fx.shake > 0 {
		k := s.fx.shake * s.fx.shake * ts * 0.16
		ox += k * float32(math.Sin(s.fx.clock*61))
		oy += k * float32(math.Sin(s.fx.clock*47))
	}
	s.origin = geom.Pt{X: ox, Y: oy}

	x0, y0 := int(ox/ts)-1, int(oy/ts)-1
	x1 := x0 + int(size.W/ts) + 3
	y1 := y0 + int(size.H/ts) + 3
	for y := y0; y <= y1; y++ {
		for x := x0; x <= x1; x++ {
			if !g.seenAt(x, y) {
				continue
			}
			s.blit(c, s.terrainTile(x, y), x, y, false, s.light(x, y))
		}
	}
	// Torch halo: a translucent warm glow centered on the player, breathing.
	fl := s.torch()
	gs := ts * 7 * fl
	c.DrawSprite(s.atlas, paint.Sprite{Src: src(TGlow),
		Dst:   geom.RectXYWH(pp.X-ox+ts/2-gs/2, pp.Y-oy+ts/2-gs/2, gs, gs),
		Alpha: 0.42 * fl})

	for _, it := range g.items {
		if g.visibleAt(it.X, it.Y) {
			s.blit(c, it.Tile, it.X, it.Y, false, s.light(it.X, it.Y))
		}
	}
	for _, m := range g.monsters {
		if m.Alive && g.visibleAt(m.X, m.Y) {
			s.entity(c, m, s.light(m.X, m.Y))
		}
	}
	s.entity(c, g.player, paint.Color{R: 1, G: 1, B: 1, A: 1})
	s.drawFloats(c)

	s.vignette(c, size)
	s.drawHUD(c, size)
	switch {
	case g.dead:
		s.summary(c, size, "You died", false)
	case g.won:
		s.summary(c, size, "You claimed the Amulet", true)
	}
}

// entity draws one creature at its eased position: shadow, sprite, and the
// white flash of a fresh wound.
func (s *gameState) entity(c paint.Canvas, e *Entity, tint paint.Color) {
	p := s.renderPos(e)
	dst := geom.RectXYWH(p.X-s.origin.X, p.Y-s.origin.Y, s.ts, s.ts)
	c.DrawSprite(s.atlas, paint.Sprite{Src: src(TShadow), Dst: dst, Nearest: true})
	c.DrawSprite(s.atlas, paint.Sprite{Src: src(e.Tile), Dst: dst, Nearest: true, FlipX: e.FlipX, Tint: tint})
	if f := s.fx.flash[e]; f > 0 {
		c.DrawSprite(s.atlas, paint.Sprite{Src: src(e.Tile), Dst: dst, Nearest: true, FlipX: e.FlipX,
			Tint: paint.Color{R: 1, G: 1, B: 1, A: 1}, Alpha: f})
	}
}

func (s *gameState) cell(x, y int) geom.Rect {
	return geom.RectXYWH(float32(x)*s.ts-s.origin.X, float32(y)*s.ts-s.origin.Y, s.ts, s.ts)
}

func (s *gameState) blit(c paint.Canvas, id TileID, x, y int, flip bool, tint paint.Color) {
	c.DrawSprite(s.atlas, paint.Sprite{Src: src(id), Dst: s.cell(x, y), Nearest: true, FlipX: flip, Tint: tint})
}

// Torchlight runs from a warm core to a cold edge, and remembered cells are
// colder still. The hue shift is doing most of the work: a falloff that only
// darkens reads as haze over the room, where warm-to-cool reads as a flame in
// the dark, and it also tells you at a glance which parts of the map you are
// looking at versus only recalling.
var (
	lightCore = paint.RGB(1.00, 0.93, 0.76) // at the torch
	lightEdge = paint.RGB(0.26, 0.25, 0.36) // at the limit of sight
	lightMem  = paint.RGB(0.20, 0.22, 0.34) // explored, out of sight
)

// light returns the tint for a cell.
func (s *gameState) light(x, y int) paint.Color {
	if !s.g.visibleAt(x, y) {
		return lightMem
	}
	d := math.Hypot(float64(x-s.g.player.X), float64(y-s.g.player.Y)) / float64(fovRadius+1)
	if d > 1 {
		d = 1
	}
	// Squared falloff, so the bright core is generous and the shoulder is
	// short — a linear ramp washes the whole room out evenly.
	t := float32(d * d)
	return paint.Color{
		R: lightCore.R + (lightEdge.R-lightCore.R)*t,
		G: lightCore.G + (lightEdge.G-lightCore.G)*t,
		B: lightCore.B + (lightEdge.B-lightCore.B)*t,
		A: 1,
	}
}

// terrainTile picks the tile for a cell. A wall with a walkable cell below it
// is a face the torch can light; every other wall is the top of the rock, and
// painting the two differently is what gives the grid depth.
func (s *gameState) terrainTile(x, y int) TileID {
	switch s.g.d.at(x, y) {
	case CellWall:
		if s.g.d.walkable(x, y+1) {
			return TWall
		}
		return TWallTop
	case CellStairs:
		return TStairs
	case CellDoor:
		return TDoor
	default:
		// Vary the floor by position so a large room is not one flat texture.
		switch (x*7 ^ y*13) % 3 {
		case 0:
			return TFloor2
		case 1:
			return TFloor3
		}
		return TFloor
	}
}

// tileSize scales tiles to the viewport so the lit radius roughly fills the
// frame. It was fixed at 32px, which on a large window left the visible island
// marooned in black — the field of view is only a few tiles wide, so the tile
// has to grow with the window rather than the count of tiles on screen. The
// scale is a whole number because these are nearest-neighbour pixel blits.
func tileSize(size geom.Size) float32 {
	const targetAcross = 19
	sc := math.Round(float64(size.W) / (targetAcross * tile))
	if sc < 2 {
		sc = 2
	}
	if sc > 6 {
		sc = 6
	}
	return float32(tile) * float32(sc)
}

// vignette darkens the frame edges, so the eye settles on the torch instead of
// the corners and the dungeon feels enclosed.
func (s *gameState) vignette(c paint.Canvas, size geom.Size) {
	const clear = 0.0
	edge := paint.Color{A: 0.55}
	none := paint.Color{}
	w := size.W * 0.20
	h := size.H * 0.20
	c.FillRRectGradient(geom.RectXYWH(0, 0, w, size.H), 0, edge, none, true)
	c.FillRRectGradient(geom.RectXYWH(size.W-w, 0, w, size.H), 0, none, edge, true)
	c.FillRRectGradient(geom.RectXYWH(0, 0, size.W, h), 0, edge, none, false)
	c.FillRRectGradient(geom.RectXYWH(0, size.H-h, size.W, h), 0, none, edge, false)
	_ = clear
}

// hudHeight is the bottom panel's height, reserved from the world viewport.
const hudHeight float32 = 90

func (s *gameState) drawHUD(c paint.Canvas, size geom.Size) {
	g := s.g
	h := hudHeight
	top := size.H - h
	c.FillRect(geom.RectXYWH(0, top, size.W, h), colPanel)
	// A lit rule along the top edge, so the panel reads as a frame around the
	// dungeon rather than a slab dropped on top of it.
	c.FillRect(geom.RectXYWH(0, top, size.W, 1), paint.Color{R: 0.55, G: 0.48, B: 0.38, A: 0.5})

	const pad = 18
	x, y := float32(pad), top+20

	// HP, with a trailing ghost so a hit reads as damage taken rather than a
	// bar that was always that length.
	const bw, bh = 190, 13
	frac := clamp01(float32(g.player.HP) / float32(g.player.MaxHP))
	c.FillRRect(geom.RectXYWH(x, y, bw, bh), bh/2, colHPbg)
	if s.hpGhost > frac {
		c.FillRRect(geom.RectXYWH(x, y, bw*s.hpGhost, bh), bh/2, paint.Color{R: 0.9, G: 0.5, B: 0.5, A: 0.35})
	}
	c.FillRRect(geom.RectXYWH(x, y, bw*frac, bh), bh/2, colHP)
	c.TextIn("bold", fmt.Sprintf("%d/%d", g.player.HP, g.player.MaxHP),
		geom.Pt{X: x + 8, Y: y + bh - 2}, 11, colInk)

	// Experience toward the next level, directly under HP: the two bars are
	// the run in one glance — how close to dying, how close to stronger.
	xy := y + bh + 6
	xf := clamp01(float32(g.xp) / float32(xpToLevel(g.level)))
	c.FillRRect(geom.RectXYWH(x, xy, bw, 5), 2.5, colHPbg)
	c.FillRRect(geom.RectXYWH(x, xy, bw*xf, 5), 2.5, colXP)

	// Stats.
	sx := x + bw + 26
	stat := func(label string, val string, col paint.Color) {
		c.TextIn("", label, geom.Pt{X: sx, Y: y + 2}, 11, colDim)
		c.TextIn("bold", val, geom.Pt{X: sx, Y: y + 18}, 15, col)
		sx += 78
	}
	stat("LEVEL", fmt.Sprintf("%d", g.level), colInk)
	stat("DEPTH", fmt.Sprintf("%d", g.depth), colInk)
	stat("GOLD", fmt.Sprintf("%d", g.gold), colCoin)
	stat("POTIONS", fmt.Sprintf("%d", g.potions), potionColor(g.potions))

	// Controls, so the game explains itself without a manual.
	c.TextIn("", "move ←↑↓→ / wasd    Q quaff    space wait",
		geom.Pt{X: size.W - 300, Y: y + 2}, 11, colDim)
	c.TextIn("", fmt.Sprintf("Amulet · depth %d", maxDepth),
		geom.Pt{X: size.W - 300, Y: y + 20}, 12, colDim)

	// The last few log lines, newest brightest, oldest fading out.
	// Two lines, sized and placed to sit inside the panel — three at 15px
	// pushed the oldest off the bottom edge.
	ly := top + 58
	n := len(g.log)
	from := max(0, n-2)
	for i, line := range g.log[from:] {
		col := colDim
		if i < len(g.log[from:])-1 {
			col.A = 0.5 // older line recedes
		}
		c.TextIn("", line, geom.Pt{X: pad, Y: ly + float32(i)*15}, 12, col)
	}
}

// potionColor greys the potion count out at zero, so "none left" is legible
// at a glance in the middle of a fight.
func potionColor(n int) paint.Color {
	if n == 0 {
		return colDim
	}
	return paint.RGB(0.72, 0.86, 0.62)
}

func clamp01(v float32) float32 {
	if v < 0 {
		return 0
	}
	if v > 1 {
		return 1
	}
	return v
}

// summary is the end-of-run card: what this run amounted to, so a death reads
// as a result rather than an interruption. A roguelike run you cannot look
// back on is just a session that stopped.
func (s *gameState) summary(c paint.Canvas, size geom.Size, title string, win bool) {
	g := s.g
	w, h := float32(360), float32(210)
	x, y := (size.W-w)/2, (size.H-hudHeight-h)/2

	c.FillRect(geom.RectXYWH(0, 0, size.W, size.H-hudHeight), colBanner)
	c.FillRRect(geom.RectXYWH(x, y, w, h), 14, paint.Color{R: 0.10, G: 0.10, B: 0.14, A: 0.97})
	accent := colHP
	if win {
		accent = colCoin
	}
	c.FillRRect(geom.RectXYWH(x, y, w, 4), 2, accent)

	tw := s.ctx.Painter().MeasureWidthIn("bold", title, 22)
	c.TextIn("bold", title, geom.Pt{X: x + (w-tw)/2, Y: y + 46}, 22, colInk)

	rows := [][2]string{
		{"Depth reached", fmt.Sprintf("%d of %d", g.depth, maxDepth)},
		{"Level", fmt.Sprintf("%d", g.level)},
		{"Monsters slain", fmt.Sprintf("%d", g.kills)},
		{"Gold", fmt.Sprintf("%d", g.gold)},
		{"Turns", fmt.Sprintf("%d", g.turns)},
	}
	ry := y + 78
	for _, r := range rows {
		c.TextIn("", r[0], geom.Pt{X: x + 28, Y: ry}, 13, colDim)
		vw := s.ctx.Painter().MeasureWidthIn("bold", r[1], 13)
		c.TextIn("bold", r[1], geom.Pt{X: x + w - 28 - vw, Y: ry}, 13, colInk)
		ry += 22
	}

	const hint = "press any key to delve again"
	hw := s.ctx.Painter().MeasureWidthIn("", hint, 12)
	c.TextIn("", hint, geom.Pt{X: x + (w-hw)/2, Y: y + h - 20}, 12, colDim)
}

// --- presentation effects -------------------------------------------------
//
// Turn-based does not have to mean snapping. Entities ease toward their new
// cell over a few frames, hits shove and flash, damage floats off, and the
// screen kicks when you are the one taking it. None of this changes a rule;
// it changes whether a turn reads as an event or as a diff.

const (
	moveEase   = 14.0 // higher settles faster
	flashDecay = 6.0
	lungeDecay = 12.0
	floatLife  = 0.9
	shakeDecay = 7.0
)

// advance steps every effect. It reports whether anything is still moving.
func (fx *effects) advance(dt float32, g *Game, ts float32) bool {
	busy := false

	// Drop entries for entities that are gone — the dead, and everything left
	// behind on previous levels. These maps are keyed by pointer, so without
	// this a long descent accumulates one entry per monster ever spawned.
	live := make(map[*Entity]bool, len(g.monsters)+1)
	for _, e := range g.all() {
		live[e] = true
	}
	for e := range fx.pos {
		if !live[e] {
			delete(fx.pos, e)
			delete(fx.flash, e)
			delete(fx.lunge, e)
		}
	}

	// Ease render positions toward the true cell.
	for _, e := range g.all() {
		want := geom.Pt{X: float32(e.X) * ts, Y: float32(e.Y) * ts}
		cur, ok := fx.pos[e]
		if !ok || ts == 0 {
			fx.pos[e] = want
			continue
		}
		d := geom.Pt{X: want.X - cur.X, Y: want.Y - cur.Y}
		if d.X*d.X+d.Y*d.Y < 0.25 {
			fx.pos[e] = want
			continue
		}
		k := min(1, dt*moveEase)
		fx.pos[e] = geom.Pt{X: cur.X + d.X*k, Y: cur.Y + d.Y*k}
		busy = true
	}

	for e, v := range fx.flash {
		v -= dt * flashDecay
		if v <= 0 {
			delete(fx.flash, e)
			continue
		}
		fx.flash[e] = v
		busy = true
	}
	for e, v := range fx.lunge {
		k := 1 - min(1, dt*lungeDecay)
		v = geom.Pt{X: v.X * k, Y: v.Y * k}
		if v.X*v.X+v.Y*v.Y < 0.2 {
			delete(fx.lunge, e)
			continue
		}
		fx.lunge[e] = v
		busy = true
	}
	if n := fx.floats[:0]; true {
		for _, f := range fx.floats {
			f.age += dt
			if f.age < floatLife {
				n = append(n, f)
				busy = true
			}
		}
		fx.floats = n
	}
	if fx.shake > 0 {
		fx.shake -= dt * shakeDecay
		if fx.shake < 0 {
			fx.shake = 0
		}
		busy = true
	}
	return busy
}

// renderPos is where an entity should be drawn: its eased position plus any
// attack lunge, falling back to the true cell before the first frame.
func (s *gameState) renderPos(e *Entity) geom.Pt {
	p, ok := s.fx.pos[e]
	if !ok {
		p = geom.Pt{X: float32(e.X) * s.ts, Y: float32(e.Y) * s.ts}
	}
	if l, ok := s.fx.lunge[e]; ok {
		p = geom.Pt{X: p.X + l.X, Y: p.Y + l.Y}
	}
	return p
}

// onHit is the game's report that a blow landed, turned into things to look at.
func (s *gameState) onHit(attacker, target *Entity, dmg int) {
	s.fx.flash[target] = 1
	if s.ts > 0 {
		dx := float32(target.X-attacker.X) * s.ts * 0.28
		dy := float32(target.Y-attacker.Y) * s.ts * 0.28
		s.fx.lunge[attacker] = geom.Pt{X: dx, Y: dy}
	}
	col := colDamage
	if target == s.g.player {
		col = colHP
		s.fx.shake = 1
	}
	s.fx.floats = append(s.fx.floats, floater{
		x: float32(target.X)*s.ts + s.ts/2, y: float32(target.Y) * s.ts,
		text: fmt.Sprintf("-%d", dmg), col: col,
	})
	s.SetState(nil)
}

// drawFloats paints the damage numbers rising off recent hits.
func (s *gameState) drawFloats(c paint.Canvas) {
	for _, f := range s.fx.floats {
		t := f.age / floatLife
		col := f.col
		col.A = 1 - t*t
		y := f.y - t*s.ts*0.9
		w := s.ctx.Painter().MeasureWidthIn("bold", f.text, 15)
		c.TextIn("bold", f.text, geom.Pt{X: f.x - s.origin.X - w/2, Y: y - s.origin.Y}, 15, col)
	}
}

// torch returns the current flicker multiplier — two offset sines, so it never
// settles into an obvious loop.
func (s *gameState) torch() float32 {
	t := s.fx.clock
	return float32(1 + 0.055*math.Sin(t*7.3) + 0.035*math.Sin(t*2.9+1.7))
}

tiles.go

package main

import (
	"image"
	"image/color"
	"math"
)

// The tileset is generated in Go at startup — no binary assets. Every tile is a
// 16×16 region of one shared atlas image, blitted with paint.DrawSprite; the
// shared atlas means one cached GPU texture for the whole map.

const tile = 16

// TileID indexes a tile in the atlas strip.
type TileID int

const (
	TFloor TileID = iota
	TFloor2
	TFloor3
	TWall
	TWallTop
	TShadow
	TPlayer
	TGoblin
	TRat
	TPotion
	TGold
	TStairs
	TDoor
	TAmulet
	TGlow
	tileCount
)

// src is the atlas source rectangle for a tile.
func src(id TileID) image.Rectangle {
	x := int(id) * tile
	return image.Rect(x, 0, x+tile, tile)
}

var (
	cOutline = color.RGBA{18, 20, 28, 255}
	cFloor   = color.RGBA{104, 98, 104, 255}
	cFloor2  = color.RGBA{124, 117, 122, 255}
	cWall    = color.RGBA{124, 110, 96, 255}
	cWall2   = color.RGBA{92, 80, 70, 255}
	// A wall seen from above catches no torchlight, so it is painted as cold
	// stone rather than a dimmer copy of the lit face. That difference is what
	// gives a flat tile grid its sense of height.
	cWallTop  = color.RGBA{46, 46, 58, 255}
	cWallTop2 = color.RGBA{38, 38, 50, 255}
	cPlayer   = color.RGBA{86, 196, 214, 255}
	cGoblin   = color.RGBA{86, 168, 78, 255}
	cRat      = color.RGBA{140, 130, 128, 255}
	cWhite    = color.RGBA{240, 244, 248, 255}
	cRed      = color.RGBA{206, 66, 68, 255}
	cGold     = color.RGBA{226, 186, 74, 255}
	cBrown    = color.RGBA{120, 84, 52, 255}
	cGlass    = color.RGBA{170, 210, 220, 255}
)

// buildAtlas draws every tile into one image and returns it.
func buildAtlas() *image.RGBA {
	a := image.NewRGBA(image.Rect(0, 0, int(tileCount)*tile, tile))

	// Floor: stone with a few lighter specks. Three variants, picked per cell
	// by position hash, so a large room does not read as one flat texture.
	floorTile(a, TFloor, [][2]int{{3, 4}, {10, 3}, {6, 11}, {12, 9}, {2, 13}, {9, 8}})
	floorTile(a, TFloor2, [][2]int{{5, 2}, {13, 6}, {2, 8}, {8, 13}, {11, 11}})
	floorTile(a, TFloor3, [][2]int{{7, 5}, {4, 9}, {12, 3}, {14, 12}, {1, 5}, {9, 2}, {6, 14}})

	// Wall face: brick with mortar lines, and a lighter top course so the
	// upper edge catches the light like a real ledge.
	fill(a, TWall, cWall)
	for y := range tile {
		for x := range tile {
			if y%5 == 4 || (x+((y/5)%2)*4)%8 == 7 {
				px(a, TWall, x, y, cWall2)
			}
		}
	}
	for x := range tile {
		px(a, TWall, x, 0, shade(cWall, 1.22))
		px(a, TWall, x, 1, shade(cWall, 1.10))
	}

	// Wall top: cold, coarse stone with no mortar — read as unlit rock above
	// the room rather than another lit face.
	fill(a, TWallTop, cWallTop)
	for y := range tile {
		for x := range tile {
			if (x*7+y*13)%11 == 0 {
				px(a, TWallTop, x, y, cWallTop2)
			}
		}
	}

	// Shadow: a soft elliptical blot that grounds an entity on its tile.
	shadowTile(a)

	// Player: cyan adventurer blob with eyes.
	creature(a, TPlayer, cPlayer, cWhite, cOutline)
	// Goblin: green, angry (red pupils).
	creature(a, TGoblin, cGoblin, cRed, cOutline)
	// Rat: small gray, with a tail.
	disc(a, TRat, 8, 9, 4, cRat)
	outline(a, TRat, cOutline)
	px(a, TRat, 13, 11, cRat)
	px(a, TRat, 14, 12, cRat)
	px(a, TRat, 6, 8, cOutline)
	px(a, TRat, 10, 8, cOutline)

	// Potion: flask with red liquid.
	rect(a, TPotion, 7, 3, 8, 5, cGlass)
	disc(a, TPotion, 8, 10, 4, cGlass)
	disc(a, TPotion, 8, 11, 3, cRed)
	outline(a, TPotion, cOutline)

	// Gold: a small pile of coins.
	for _, p := range [][2]int{{6, 10}, {9, 10}, {7, 7}} {
		disc(a, TGold, p[0], p[1], 2, cGold)
	}

	// Stairs down: nested steps.
	for i := range 4 {
		rect(a, TStairs, 3+i, 3+i*3, 12, 5+i*3, shade(cFloor2, 1-float64(i)*0.18))
	}

	// Door: brown with a knob.
	rect(a, TDoor, 3, 2, 12, 13, cBrown)
	outlineRect(a, TDoor, 3, 2, 12, 13, cOutline)
	px(a, TDoor, 10, 7, cGold)

	// Amulet: gold medallion with a red gem and a chain.
	px(a, TAmulet, 8, 3, cGold)
	px(a, TAmulet, 8, 4, cGold)
	disc(a, TAmulet, 8, 9, 4, cGold)
	disc(a, TAmulet, 8, 9, 2, cRed)
	outline(a, TAmulet, cOutline)

	// Glow: a soft radial warm halo (premultiplied alpha) for the torch aura.
	glowTile(a)

	return a
}

// floorTile paints one floor variant: a base fill plus lighter grit.
func floorTile(a *image.RGBA, id TileID, specks [][2]int) {
	fill(a, id, cFloor)
	for _, p := range specks {
		px(a, id, p[0], p[1], cFloor2)
	}
}

// shadowTile paints a soft dark ellipse, wider than it is tall, sitting in the
// lower half of the tile where a standing figure's feet are.
func shadowTile(a *image.RGBA) {
	const cx, cy = 7.5, 11.0
	for y := range tile {
		for x := range tile {
			dx := (float64(x) - cx) / 5.5
			dy := (float64(y) - cy) / 2.6
			f := 1 - math.Sqrt(dx*dx+dy*dy)
			if f < 0 {
				f = 0
			}
			f *= f * 0.55
			ax, ay := at(TShadow, x, y)
			a.SetRGBA(ax, ay, color.RGBA{A: uint8(255 * f)})
		}
	}
}

func glowTile(a *image.RGBA) {
	const cx, cy = 7.5, 7.5
	for y := range tile {
		for x := range tile {
			f := 1 - math.Hypot(float64(x)-cx, float64(y)-cy)/8
			if f < 0 {
				f = 0
			}
			f *= f
			ax, ay := at(TGlow, x, y)
			a.SetRGBA(ax, ay, color.RGBA{
				R: uint8(255 * f), G: uint8(238 * f), B: uint8(205 * f), A: uint8(255 * f)})
		}
	}
}

// creature draws a rounded body with two eyes — the shared entity shape.
func creature(a *image.RGBA, id TileID, body, eye, out color.RGBA) {
	disc(a, id, 8, 9, 5, body)
	outline(a, id, out)
	disc(a, id, 6, 8, 1, cWhite)
	disc(a, id, 10, 8, 1, cWhite)
	px(a, id, 6, 8, eye)
	px(a, id, 10, 8, eye)
}

// --- low-level tile painters (tile-local coordinates) ---

func at(id TileID, x, y int) (int, int) { return int(id)*tile + x, y }

func px(a *image.RGBA, id TileID, x, y int, c color.RGBA) {
	if x < 0 || y < 0 || x >= tile || y >= tile {
		return
	}
	ax, ay := at(id, x, y)
	a.SetRGBA(ax, ay, c)
}

func fill(a *image.RGBA, id TileID, c color.RGBA) {
	for y := range tile {
		for x := range tile {
			px(a, id, x, y, c)
		}
	}
}

func rect(a *image.RGBA, id TileID, x0, y0, x1, y1 int, c color.RGBA) {
	for y := y0; y <= y1; y++ {
		for x := x0; x <= x1; x++ {
			px(a, id, x, y, c)
		}
	}
}

func disc(a *image.RGBA, id TileID, cx, cy, r int, c color.RGBA) {
	for y := -r; y <= r; y++ {
		for x := -r; x <= r; x++ {
			if x*x+y*y <= r*r {
				px(a, id, cx+x, cy+y, c)
			}
		}
	}
}

// outline darkens the transparent-adjacent border of already-drawn body pixels.
func outline(a *image.RGBA, id TileID, out color.RGBA) {
	// Collect the edge pixels first, then write them.
	//
	// Writing into the image while still scanning it makes each freshly
	// outlined pixel look like body on the next iteration, so the outline
	// seeds another outline and floods the whole tile. That is why every
	// creature and item used to sit on an opaque black square, unable to
	// stand on the lit floor beneath it.
	var edge [][2]int
	for y := range tile {
		for x := range tile {
			ax, ay := at(id, x, y)
			if a.RGBAAt(ax, ay).A == 0 {
				continue
			}
			for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
				nx, ny := x+d[0], y+d[1]
				if nx < 0 || ny < 0 || nx >= tile || ny >= tile {
					continue
				}
				bx, by := at(id, nx, ny)
				if a.RGBAAt(bx, by).A == 0 {
					edge = append(edge, [2]int{nx, ny})
				}
			}
		}
	}
	for _, p := range edge {
		px(a, id, p[0], p[1], out)
	}
}

func outlineRect(a *image.RGBA, id TileID, x0, y0, x1, y1 int, c color.RGBA) {
	for x := x0; x <= x1; x++ {
		px(a, id, x, y0, c)
		px(a, id, x, y1, c)
	}
	for y := y0; y <= y1; y++ {
		px(a, id, x0, y, c)
		px(a, id, x1, y, c)
	}
}

func shade(c color.RGBA, f float64) color.RGBA {
	cl := func(v uint8) uint8 {
		n := float64(v) * f
		if n > 255 {
			n = 255
		}
		return uint8(n)
	}
	return color.RGBA{cl(c.R), cl(c.G), cl(c.B), c.A}
}