code.pablomurad.com

bbs-hammer

SSH BBS with forums, chat, door games, retro terminals and a weather service, written in Go

feat(bbs): NES games through nesdoor
author
Pablo Murad
date
commit
6cc53f036bc2aa5aafb4bef1f8f1ee57dae0f439
parent
68b2ca152109a21a86a387825016cc962288b9b3
changes
13 files +678 −5
@@ -56,6 +56,8 @@ // Style picks the default theme and the modem effect speed. 	Style *StyleConfig `yaml:"style,omitempty"`
 
 	Tools *ToolsConfig `yaml:"tools,omitempty"`
+	// Games runs NES games through the nesdoor door (see games.go).
+	Games *GamesConfig `yaml:"games,omitempty"`
 
 	// Version is the binary version, injected by main; not a config key.
 	Version string `yaml:"-"`
@@ -158,6 +160,9 @@ if _, err := time.ParseDuration(v); err != nil { 				return fmt.Errorf("bbs.jobs.%s %q is not a valid duration: %w", name, v, err)
 			}
 		}
+	}
+	if err := c.Games.validate(); err != nil {
+		return err
 	}
 	if c.Files != nil && (c.Files.QuotaBytes < 0 || c.Files.MaxFileInput < 0) {
 		return fmt.Errorf("bbs.files sizes must be >= 0")
@@ -0,0 +1,180 @@+package bbs
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+)
+
+// NES games come from nesdoor (https://github.com/AlexCorris/nesdoor), a BBS
+// door that draws the picture in sixel graphics. It is a separate GPL-2.0
+// program run as a child process, never linked into Hammer. The menu offers
+// games only when the door is configured and found on the host, the same way
+// lychee, DuckDB and MTR appear only when installed.
+
+const (
+	defaultGamePlayers = 3
+	defaultGameMinutes = 25
+	defaultDoorTempDir = "cache/doors"
+	// doorGrace is how long a door gets to save and exit after the caller
+	// hangs up, before it is killed.
+	doorGrace = 5 * time.Second
+)
+
+// GamesConfig is the optional "bbs.games" block.
+type GamesConfig struct {
+	Enabled bool `yaml:"enabled"`
+	// DoorDir is the nesdoor folder: nesdoor.py, the libretro core, and the
+	// saves/ and sessions/ folders the door writes.
+	DoorDir string `yaml:"doorDir"`
+	// Python runs the door. Absent = DoorDir/venv/bin/python, else python3.
+	Python string `yaml:"python"`
+	// RomsDir holds the .nes files. Absent = DoorDir/roms.
+	RomsDir string `yaml:"romsDir"`
+	// MaxPlayers is how many people may play at once. Absent or 0 = 3.
+	MaxPlayers int `yaml:"maxPlayers"`
+	// MaxMinutes caps one sitting; the door saves and exits on time. The SSH
+	// connection's own limit caps it too. Absent or 0 = 25.
+	MaxMinutes int `yaml:"maxMinutes"`
+	// AllowAnonymous lets keyless sessions play (without saves that last).
+	AllowAnonymous bool `yaml:"allowAnonymous"`
+}
+
+func (g *GamesConfig) validate() error {
+	if g == nil {
+		return nil
+	}
+	if g.MaxPlayers < 0 || g.MaxMinutes < 0 {
+		return fmt.Errorf("bbs.games.maxPlayers and maxMinutes must be >= 0")
+	}
+	if g.Enabled && g.DoorDir == "" {
+		return fmt.Errorf("bbs.games.doorDir is required when games are enabled")
+	}
+
+	return nil
+}
+
+// gamesSettings is a resolved, runnable games setup.
+type gamesSettings struct {
+	program    string   // the interpreter (or, in tests, the door itself)
+	args       []string // arguments that start the door
+	env        []string // extra environment for the door (tests)
+	dir        string   // the door's working directory
+	tempDir    string   // where per-run drop files go
+	maxPlayers int
+	maxTime    time.Duration
+	anonymous  bool
+}
+
+// resolveGames checks the host for what the config names. It returns nil,
+// with the reason logged, when games cannot run here.
+func resolveGames(g *GamesConfig) *gamesSettings {
+	if g == nil || !g.Enabled {
+		return nil
+	}
+	off := func(why string, args ...any) *gamesSettings {
+		log.Printf("bbs: games disabled: "+why, args...)
+
+		return nil
+	}
+
+	if runtime.GOOS == "windows" {
+		// nesdoor's stdio mode waits on its pipes with select(), which
+		// Windows only offers for sockets.
+		return off("the door's stdio mode needs a Unix host")
+	}
+
+	dir, err := filepath.Abs(g.DoorDir)
+	if err != nil {
+		return off("doorDir %q: %v", g.DoorDir, err)
+	}
+	script := filepath.Join(dir, "nesdoor.py")
+	if _, err := os.Stat(script); err != nil {
+		return off("%s not found", script)
+	}
+
+	python := findPython(g.Python, dir)
+	if python == "" {
+		return off("no Python interpreter found (set bbs.games.python)")
+	}
+
+	roms := g.RomsDir
+	if roms == "" {
+		roms = filepath.Join(dir, "roms")
+	}
+	if roms, err = filepath.Abs(roms); err != nil {
+		return off("romsDir %q: %v", g.RomsDir, err)
+	}
+	if n := countROMs(roms); n == 0 {
+		log.Printf("bbs: games: no .nes files in %s yet", roms)
+	} else {
+		log.Printf("bbs: games: %d games in %s", n, roms)
+	}
+
+	players := g.MaxPlayers
+	if players <= 0 {
+		players = defaultGamePlayers
+	}
+	minutes := g.MaxMinutes
+	if minutes <= 0 {
+		minutes = defaultGameMinutes
+	}
+
+	return &gamesSettings{
+		program:    python,
+		args:       []string{script, "--roms", roms, "--io", "stdio"},
+		dir:        dir,
+		tempDir:    defaultDoorTempDir,
+		maxPlayers: players,
+		maxTime:    time.Duration(minutes) * time.Minute,
+		anonymous:  g.AllowAnonymous,
+	}
+}
+
+// findPython resolves the configured interpreter, else the door's own venv,
+// else python3 on the PATH.
+func findPython(configured, doorDir string) string {
+	if configured != "" {
+		if _, err := os.Stat(configured); err == nil {
+			return configured
+		}
+		if p, err := exec.LookPath(configured); err == nil {
+			return p
+		}
+
+		return ""
+	}
+
+	venv := filepath.Join(doorDir, "venv", "bin", "python")
+	if _, err := os.Stat(venv); err == nil {
+		return venv
+	}
+	for _, name := range []string{"python3", "python"} {
+		if p, err := exec.LookPath(name); err == nil {
+			return p
+		}
+	}
+
+	return ""
+}
+
+// countROMs is how many .nes files the door will list.
+func countROMs(dir string) int {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return 0
+	}
+	n := 0
+	for _, e := range entries {
+		if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".nes") {
+			n++
+		}
+	}
+
+	return n
+}
@@ -0,0 +1,224 @@+package bbs
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/runawaydevil/hammer/internal/community"
+)
+
+// The test binary doubles as a fake door: with HAMMER_FAKE_DOOR set it
+// behaves like nesdoor would, instead of running the tests.
+func TestMain(m *testing.M) {
+	if os.Getenv("HAMMER_FAKE_DOOR") != "" {
+		fakeGameDoor()
+		os.Exit(0)
+	}
+	os.Exit(m.Run())
+}
+
+// fakeGameDoor greets the caller by alias, echoes keys in upper case, names
+// Ctrl+C, leaves on "q", and on hang-up leaves a "saved" file behind.
+func fakeGameDoor() {
+	data, err := os.ReadFile(os.Getenv("BBSDEV_DRP"))
+	if err != nil {
+		fmt.Print("NO DROP FILE\r\n")
+
+		return
+	}
+	lines := strings.Split(string(data), "\n")
+	fmt.Printf("DOOR HELLO %s\r\n", lines[3])
+
+	r := bufio.NewReader(os.Stdin)
+	for {
+		b, err := r.ReadByte()
+		if err != nil {
+			_ = os.WriteFile("saved", []byte(lines[4]), 0o600)
+
+			return
+		}
+		switch b {
+		case 'q':
+			fmt.Print("DOOR BYE\r\n")
+
+			return
+		case 0x03:
+			fmt.Print("CTRL-C")
+		default:
+			fmt.Print(strings.ToUpper(string(b)))
+		}
+	}
+}
+
+func fakeGames(t *testing.T, players int) *gamesSettings {
+	t.Helper()
+
+	return &gamesSettings{
+		program:    os.Args[0],
+		dir:        t.TempDir(),
+		tempDir:    t.TempDir(),
+		env:        []string{"HAMMER_FAKE_DOOR=1"},
+		maxPlayers: players,
+		maxTime:    20 * time.Minute,
+	}
+}
+
+func whereIs(srv *Server, nick string) string {
+	for _, o := range srv.presence.List() {
+		if o.Nick == nick {
+			return o.Where
+		}
+	}
+
+	return ""
+}
+
+func TestGamesHiddenWithoutTheDoor(t *testing.T) {
+	srv := testServer(t, &Config{})
+	addr := listen(t, srv)
+
+	ana := login(t, addr, "ana", newSigner(t))
+	ana.never("NES in sixel")
+	ana.send("g")
+	ana.waitFor("unknown key")
+}
+
+func TestGamesMisconfiguredStaysHidden(t *testing.T) {
+	srv := testServer(t, &Config{Games: &GamesConfig{Enabled: true, DoorDir: t.TempDir()}})
+	if srv.games != nil {
+		t.Fatal("games on without nesdoor.py")
+	}
+}
+
+func TestGamesRunTheDoor(t *testing.T) {
+	srv := testServer(t, &Config{})
+	srv.setGames(fakeGames(t, 2))
+	addr := listen(t, srv)
+
+	ana := login(t, addr, "ana", newSigner(t))
+	ana.waitFor("NES in sixel")
+	ana.send("g")
+	ana.waitFor("GAMES")
+	ana.send("\r")
+	ana.waitFor("DOOR HELLO ana")
+
+	if w := whereIs(srv, "ana"); w != community.WhereGame {
+		t.Fatalf("ana is %q, want %q", w, community.WhereGame)
+	}
+	if srv.gameSlots.InUse() != 1 {
+		t.Fatalf("slots in use = %d", srv.gameSlots.InUse())
+	}
+
+	// Keys reach the door raw: Ctrl+C belongs to the game, not the BBS.
+	ana.send("x\x03y")
+	ana.waitFor("XCTRL-CY")
+
+	mark := ana.mark()
+	ana.send("q")
+	ana.waitFor("DOOR BYE")
+	ana.waitSince(mark, "HAMMER")
+
+	if w := whereIs(srv, "ana"); w != community.WhereMenu {
+		t.Fatalf("after the game ana is %q", w)
+	}
+	if srv.gameSlots.InUse() != 0 {
+		t.Fatalf("slot not released: %d in use", srv.gameSlots.InUse())
+	}
+
+	// The BBS still works after the door.
+	ana.send("a")
+	ana.waitFor("press any key")
+}
+
+func TestGamesBackOutWithoutPlaying(t *testing.T) {
+	srv := testServer(t, &Config{})
+	srv.setGames(fakeGames(t, 1))
+	addr := listen(t, srv)
+
+	ana := login(t, addr, "ana", newSigner(t))
+	ana.send("g")
+	ana.waitFor("GAMES")
+	mark := ana.mark()
+	ana.send("b")
+	ana.waitSince(mark, "NES in sixel")
+	ana.never("DOOR HELLO")
+}
+
+func TestGamesNeedAKey(t *testing.T) {
+	srv := testServer(t, &Config{AllowAnonymous: true})
+	srv.setGames(fakeGames(t, 1))
+	addr := listen(t, srv)
+
+	guest := login(t, addr, "guest", nil)
+	guest.send("g")
+	guest.waitFor("Games need an SSH key")
+	guest.never("DOOR HELLO")
+}
+
+func TestGamesFull(t *testing.T) {
+	srv := testServer(t, &Config{})
+	srv.setGames(fakeGames(t, 1))
+	addr := listen(t, srv)
+
+	ana := login(t, addr, "ana", newSigner(t))
+	ana.send("g")
+	ana.waitFor("GAMES")
+	ana.send("\r")
+	ana.waitFor("DOOR HELLO ana")
+
+	bia := login(t, addr, "bia", newSigner(t))
+	bia.send("g")
+	bia.waitFor("GAMES")
+	bia.send("\r")
+	bia.waitFor("All 1 game slots are taken")
+	bia.never("DOOR HELLO")
+
+	// Leave cleanly, so the door is gone before the test's folders are.
+	ana.send("q")
+	ana.waitFor("DOOR BYE")
+	waitNoGames(t, srv)
+}
+
+// waitNoGames waits until every door has exited and freed its node.
+func waitNoGames(t *testing.T, srv *Server) {
+	t.Helper()
+	deadline := time.Now().Add(5 * time.Second)
+	for srv.gameSlots.InUse() > 0 {
+		if time.Now().After(deadline) {
+			t.Fatalf("%d games still running", srv.gameSlots.InUse())
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+}
+
+func TestGamesHangUpLetsTheDoorSave(t *testing.T) {
+	srv := testServer(t, &Config{})
+	g := fakeGames(t, 1)
+	srv.setGames(g)
+	addr := listen(t, srv)
+
+	signer := newSigner(t)
+	client := dial(t, addr, signer)
+	tm := openTerm(t, client, "")
+	tm.send("\r")
+	tm.waitFor("HAMMER")
+	tm.send("g")
+	tm.waitFor("GAMES")
+	tm.send("\r")
+	tm.waitFor("DOOR HELLO")
+
+	client.Close() // the line drops mid-game
+
+	deadline := time.Now().Add(10 * time.Second)
+	for time.Now().Before(deadline) {
+		if _, err := os.Stat(g.dir + "/saved"); err == nil && srv.gameSlots.InUse() == 0 {
+			return
+		}
+		time.Sleep(20 * time.Millisecond)
+	}
+	t.Fatalf("door did not save on hang-up, or the slot was kept (%d in use)", srv.gameSlots.InUse())
+}
@@ -201,6 +201,19 @@ "BBS_UNKNOWN_WALL":     "unknown option — try p, o or b", 	"BBS_BANNED":           "You are banned from posting",
 	"BBS_WHERE_MENU":       "menu",
 	"BBS_WHERE_CHAT":       "in the chat",
+	"BBS_GAMES":            "games",
+	"BBS_GAMES_SUM":        "NES in sixel",
+	"BBS_TITLE_GAMES":      "GAMES",
+	"BBS_GAMES_INTRO1":     "Homebrew NES games, drawn in sixel graphics.",
+	"BBS_GAMES_INTRO2":     "Needs a sixel terminal: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
+	"BBS_GAMES_INTRO3":     "Sound and held keys work in SyncTERM and IcyTerm.",
+	"BBS_GAMES_INTRO4":     "Up to %d minutes a sitting; leaving saves your game.",
+	"BBS_GAMES_PLAY":       "play",
+	"BBS_GAMES_ANON":       "Games need an SSH key — connect with one to play",
+	"BBS_GAMES_FULL":       "All %d game slots are taken — try again soon",
+	"BBS_GAMES_LATE":       "This connection is about to time out — reconnect to play",
+	"BBS_GAMES_FAILED":     "The games could not start — try again later",
+	"BBS_WHERE_GAME":       "playing",
 	"BBS_ANON_TAG":         "anonymous",
 	"BBS_CONNS":            "%d connections",
 	"BBS_CHAT_TITLE":       "CHAT · #lobby",
@@ -0,0 +1,194 @@+package bbs
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/hex"
+	"io"
+	"log"
+	"os"
+	"path/filepath"
+	"time"
+
+	"github.com/runawaydevil/hammer/internal/community"
+	"github.com/runawaydevil/hammer/internal/door"
+	"github.com/runawaydevil/hammer/internal/localization"
+)
+
+// setGames installs a games setup; nil turns games off.
+func (s *Server) setGames(g *gamesSettings) {
+	s.games = g
+	s.gameSlots = nil
+	if g != nil {
+		s.gameSlots = door.NewSlots(g.maxPlayers)
+	}
+}
+
+// gamesOn reports whether the menu offers games.
+func (st *session) gamesOn() bool {
+	return st.srv != nil && st.srv.games != nil
+}
+
+// gamesMenu explains what the games need, then hands the terminal to the
+// door until the caller leaves it.
+func (st *session) gamesMenu() {
+	g := st.srv.games
+	if st.anon && !g.anonymous {
+		st.note = st.t("BBS_GAMES_ANON")
+
+		return
+	}
+
+	st.clear()
+	lines := []string{
+		"",
+		" " + st.th.Value + st.t("BBS_GAMES_INTRO1") + st.th.Reset,
+		"",
+		" " + st.th.Dim + st.t("BBS_GAMES_INTRO2") + st.th.Reset,
+		" " + st.th.Dim + st.t("BBS_GAMES_INTRO3") + st.th.Reset,
+		" " + st.th.Dim + st.tf("BBS_GAMES_INTRO4", int(g.maxTime.Minutes())) + st.th.Reset,
+		"",
+	}
+	box := st.th.Box(st.th.Heading(st.t("BBS_TITLE_GAMES")), lines, 56)
+	box = append(box, st.th.HintBar([][2]string{{"enter", st.t("BBS_GAMES_PLAY")}, {"b", st.t("BBS_BACK")}}))
+	st.screen(box)
+
+	if c := st.choose("games> "); c != '\r' && c != '\n' {
+		return
+	}
+
+	// The door saves and exits at the deadline; it must come before the SSH
+	// connection's own limit cuts the line.
+	deadline := time.Now().Add(g.maxTime)
+	if end := st.since.Add(maxTimeout - time.Minute); end.Before(deadline) {
+		deadline = end
+	}
+	if time.Until(deadline) < 2*time.Minute {
+		st.note = st.t("BBS_GAMES_LATE")
+
+		return
+	}
+
+	node, ok := st.srv.gameSlots.Acquire()
+	if !ok {
+		st.note = st.tf("BBS_GAMES_FULL", g.maxPlayers)
+
+		return
+	}
+	defer st.srv.gameSlots.Release(node)
+
+	if st.pres != nil {
+		st.pres.SetWhere(community.WhereGame)
+		defer st.pres.SetWhere(community.WhereMenu)
+	}
+
+	lang := "en"
+	if st.profile.Lang != "" {
+		lang = localization.Canonical(st.profile.Lang)
+	}
+	spec := door.Spec{
+		Name:    "nesdoor",
+		Program: g.program,
+		Args:    g.args,
+		Dir:     g.dir,
+		TempDir: g.tempDir,
+		Env:     g.env,
+		Drop: door.Drop{
+			Alias:    st.profile.Nick,
+			UserKey:  st.fp,
+			Width:    st.width0(),
+			Height:   st.height0(),
+			Deadline: deadline,
+			Language: lang,
+			Board:    "Hammer",
+			Sysop:    "hammer",
+			Node:     node,
+		},
+	}
+
+	st.clear()
+	p, err := door.Start(spec, doorOutput{st})
+	if err != nil {
+		log.Println("bbs: games:", err)
+		st.note = st.t("BBS_GAMES_FAILED")
+
+		return
+	}
+	st.relayDoor(p, deadline)
+	if err := p.Err(); err != nil && st.alive() {
+		log.Println("bbs: games: door exited:", err)
+	}
+	if st.anon {
+		st.forgetAnonymousSaves(g)
+	}
+
+	st.writeString("\x1b[0m\x1b[?25h")
+	st.clear()
+}
+
+// relayDoor passes the caller's keys to the door until the door exits, the
+// caller hangs up, or the sitting runs past its deadline. Keys go through
+// raw: Ctrl+C, Esc and the rest belong to the game now.
+func (st *session) relayDoor(p *door.Process, deadline time.Time) {
+	// Typed-ahead keys go first, but not the LF or NUL some terminals send
+	// after the Enter that opened the door: it would pick the first game.
+	pending := bytes.TrimLeft(st.inbuf, "\n\x00")
+	st.inbuf = nil
+	if len(pending) > 0 {
+		_, _ = p.Write(pending)
+	}
+
+	// A door that ignores its own deadline is hung up on shortly after.
+	late := time.NewTimer(time.Until(deadline) + 30*time.Second)
+	defer late.Stop()
+
+	for {
+		select {
+		case <-p.Done():
+			return
+		case ch, ok := <-st.in:
+			if !ok || ch.err != nil {
+				err := ch.err
+				if err == nil {
+					err = io.EOF
+				}
+				st.setErr(err)
+				p.Hangup(doorGrace)
+
+				return
+			}
+			// A failed write means the door is on its way out; Done follows.
+			_, _ = p.Write(ch.b)
+		case <-late.C:
+			p.Hangup(doorGrace)
+
+			return
+		}
+	}
+}
+
+// forgetAnonymousSaves removes the saves an anonymous caller left behind:
+// the identity was throwaway, so nobody can come back for them. nesdoor
+// keeps a player's saves in saves/key-<first 16 hex of sha256(user key)>.
+func (st *session) forgetAnonymousSaves(g *gamesSettings) {
+	sum := sha256.Sum256([]byte(st.fp))
+	_ = os.RemoveAll(filepath.Join(g.dir, "saves", "key-"+hex.EncodeToString(sum[:])[:16]))
+}
+
+// doorOutput writes a door's output to the session whole, under the write
+// lock, so nothing else lands in the middle of a sixel frame. Once the line
+// is gone it discards, so the door never blocks writing to nobody.
+type doorOutput struct{ st *session }
+
+func (o doorOutput) Write(b []byte) (int, error) {
+	o.st.wmu.Lock()
+	defer o.st.wmu.Unlock()
+
+	if o.st.err == nil && o.st.sess != nil {
+		if _, err := o.st.sess.Write(b); err != nil {
+			o.st.err = err
+		}
+	}
+
+	return len(b), nil
+}
@@ -58,6 +58,11 @@ } 	lines = append(lines,
 		st.menuEntry("w", st.t("BBS_WALL"), st.t("BBS_WALL_SUM")),
 		st.menuEntry("c", st.t("BBS_CHAT"), st.t("BBS_CHAT_SUM")),
+	)
+	if st.gamesOn() {
+		lines = append(lines, st.menuEntry("g", st.t("BBS_GAMES"), st.t("BBS_GAMES_SUM")))
+	}
+	lines = append(lines,
 		st.menuEntry("s", st.t("BBS_SETTINGS"), ""),
 		st.menuEntry("a", st.t("BBS_ABOUT"), ""),
 		st.menuEntry("m", st.t("BBS_MORE_TOOLS"), st.t("BBS_MORE_TOOLS_SUM")),
@@ -174,8 +174,11 @@ func (st *session) whoLines() []string { 	var rows []string
 	for _, o := range st.srv.presence.List() {
 		where := st.t("BBS_WHERE_MENU")
-		if o.Where == community.WhereChat {
+		switch o.Where {
+		case community.WhereChat:
 			where = st.t("BBS_WHERE_CHAT")
+		case community.WhereGame:
+			where = st.t("BBS_WHERE_GAME")
 		}
 		extra := ""
 		if o.Anonymous {
@@ -18,6 +18,7 @@ 	"github.com/runawaydevil/hammer/internal/community"
 	"github.com/runawaydevil/hammer/internal/dispatch"
 	"github.com/runawaydevil/hammer/internal/domain"
+	"github.com/runawaydevil/hammer/internal/door"
 	"github.com/runawaydevil/hammer/internal/jobs"
 	"github.com/runawaydevil/hammer/internal/localization"
 	"github.com/runawaydevil/hammer/internal/ratelimit"
@@ -57,6 +58,9 @@ communityCfg communitySettings 	presence     *community.Presence
 	wall         *community.Wall
 	chat         *community.Hub
+
+	games     *gamesSettings // nil when games are off or the door is missing
+	gameSlots *door.Slots    // nodes, one per person playing
 }
 
 func New(cfg *Config, ws *weather.WeatherService, ip ipDataLocator) (*Server, error) {
@@ -114,6 +118,8 @@ } 	cc := s.communityCfg
 	s.wall = community.NewWall(cst, cc.wallMaxLen, cc.wallCooldown)
 	s.chat = community.NewHub(cst, cc.chatMaxLen, cc.chatPerMinute, cc.chatBurst)
+
+	s.setGames(resolveGames(cfg.Games))
 
 	catalog, err := dispatch.NewCatalog(s.dispatchTools()...)
 	if err != nil {
@@ -177,6 +177,12 @@ case 'w', 'W': 			st.wallMenu()
 		case 'c', 'C':
 			st.chatRoom()
+		case 'g', 'G':
+			if st.gamesOn() {
+				st.gamesMenu()
+			} else {
+				st.note = st.tf("BBS_UNKNOWN_MAIN", st.mainNumericRange())
+			}
 		case 'q', 'Q':
 			st.farewell()
 
@@ -103,10 +103,15 @@ if list[0].Nick != "ana" || list[0].Conns != 2 || list[1].Anonymous != true { 		t.Errorf("list = %+v", list)
 	}
 
+	a1.SetWhere(WhereGame)
+	if p.List()[0].Where != WhereGame {
+		t.Error("a game shows over the menu")
+	}
 	a2.SetWhere(WhereChat)
 	if p.List()[0].Where != WhereChat {
 		t.Error("a person with any connection in the chat is in the chat")
 	}
+	a1.SetWhere(WhereMenu)
 
 	a1.SetNick("ana2")
 	a2.Leave()
@@ -11,6 +11,7 @@ // Where a person is in the BBS. const (
 	WhereMenu = "menu"
 	WhereChat = "chat"
+	WhereGame = "game"
 )
 
 // Online is one person in the "who is online" list. Several connections from
@@ -66,7 +67,7 @@ 	delete(c.p.conns, c.id)
 }
 
-// SetWhere records where the connection is (WhereMenu, WhereChat).
+// SetWhere records where the connection is (WhereMenu, WhereChat, WhereGame).
 func (c *Conn) SetWhere(where string) {
 	c.p.mu.Lock()
 	defer c.p.mu.Unlock()
@@ -99,8 +100,13 @@ o.Conns++ 		if c.since.Before(o.Since) {
 			o.Since = c.since
 		}
-		if c.where == WhereChat {
+		// Several connections from one person: the chat shows first, then a
+		// game, then the menu.
+		switch {
+		case c.where == WhereChat:
 			o.Where = WhereChat
+		case c.where == WhereGame && o.Where != WhereChat:
+			o.Where = WhereGame
 		}
 	}
 
@@ -226,5 +226,18 @@ "BBS_THEME": "theme",   "BBS_THEME_RETRO90": "retro 90s",
   "BBS_THEME_MODERN": "retro-modern",
   "BBS_THEME_PLAIN": "plain",
-  "BBS_FX": "modem effect"
+  "BBS_FX": "modem effect",
+  "BBS_GAMES": "games",
+  "BBS_GAMES_SUM": "NES in sixel",
+  "BBS_TITLE_GAMES": "GAMES",
+  "BBS_GAMES_INTRO1": "Homebrew NES games, drawn in sixel graphics.",
+  "BBS_GAMES_INTRO2": "Needs a sixel terminal: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
+  "BBS_GAMES_INTRO3": "Sound and held keys work in SyncTERM and IcyTerm.",
+  "BBS_GAMES_INTRO4": "Up to %d minutes a sitting; leaving saves your game.",
+  "BBS_GAMES_PLAY": "play",
+  "BBS_GAMES_ANON": "Games need an SSH key — connect with one to play",
+  "BBS_GAMES_FULL": "All %d game slots are taken — try again soon",
+  "BBS_GAMES_LATE": "This connection is about to time out — reconnect to play",
+  "BBS_GAMES_FAILED": "The games could not start — try again later",
+  "BBS_WHERE_GAME": "playing"
 }
@@ -226,5 +226,18 @@ "BBS_THEME": "tema",   "BBS_THEME_RETRO90": "retrô anos 90",
   "BBS_THEME_MODERN": "retrô-moderno",
   "BBS_THEME_PLAIN": "simples",
-  "BBS_FX": "efeito de modem"
+  "BBS_FX": "efeito de modem",
+  "BBS_GAMES": "jogos",
+  "BBS_GAMES_SUM": "NES em sixel",
+  "BBS_TITLE_GAMES": "JOGOS",
+  "BBS_GAMES_INTRO1": "Jogos homebrew de NES, desenhados em sixel.",
+  "BBS_GAMES_INTRO2": "Precisa de terminal com sixel: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
+  "BBS_GAMES_INTRO3": "Som e teclas seguradas funcionam no SyncTERM e no IcyTerm.",
+  "BBS_GAMES_INTRO4": "Até %d minutos por partida; ao sair, o jogo é salvo.",
+  "BBS_GAMES_PLAY": "jogar",
+  "BBS_GAMES_ANON": "Os jogos precisam de uma chave SSH — conecte com uma para jogar",
+  "BBS_GAMES_FULL": "As %d vagas de jogo estão ocupadas — tente daqui a pouco",
+  "BBS_GAMES_LATE": "Esta conexão está perto do limite de tempo — reconecte para jogar",
+  "BBS_GAMES_FAILED": "Os jogos não puderam iniciar — tente mais tarde",
+  "BBS_WHERE_GAME": "jogando"
 }