TL;DR: The thing that broke me was watching a junior dev stare at
kubectl get pods -Aoutput for 45 seconds, completely lost. Not because they weren't smart — they just had no mental model for what a cluster is.
📖 Reading time: ~40 min
What's in this article
- Why I Built a Pokémon Game on Top of Kubernetes
- Architecture Overview Before You Write a Single Line
- Setting Up Your Dev Environment
- Building the Kubernetes Adapter Layer
- The Bubble Tea State Machine — Your Game Engine
- Drawing the Overworld: ASCII Sprites with Lipgloss
- The Battle Screen: kubectl exec as a 'Move'
- Wiring a Real Cluster: RBAC and the Least-Privilege Kubeconfig
Why I Built a Pokémon Game on Top of Kubernetes
The thing that broke me was watching a junior dev stare at kubectl get pods -A output for 45 seconds, completely lost. Not because they weren't smart — they just had no mental model for what a cluster is. Every dashboard I tried to hand them either required you to already understand Kubernetes to use it, or was so stripped down it taught nothing. k9s is genuinely great once you're past the learning curve, but throwing it at someone on day two is like handing someone a Bloomberg terminal and saying "just explore."
So here's the weird thought I couldn't shake: Pallet Town works as onboarding because the game forces you to discover things spatially. You walk into tall grass and something happens. You enter a building and there's context. What if a Kubernetes cluster felt like that? What if instead of a flat list of namespaces, you were walking between towns — and instead of reading pod status codes, a Snorlax blocked your path until CrashLoopBackOff was resolved?
That's Project Yellow Olive in one sentence: a terminal UI game where your cluster is a map, namespaces are towns, running pods are wild Pokémon you can encounter, and "battling" one means opening a kubectl exec session into it. The name comes from mixing Pokémon Yellow's aesthetic with the olive-drab feel of terminal UIs. It's not a toy — it actually reads your cluster state in real time. A pod going Pending spawns a different encounter type than one that's Running. Deployments become gyms you can challenge. ConfigMaps are the item shops. Every bit of it maps to something real in Kubernetes that a junior dev now has emotional context for.
The stack I landed on after some false starts: Go 1.22 (the range-over-function experiment in 1.22 is genuinely useful for iterating over cluster resources), Bubble Tea v0.25 for the TUI framework, client-go v0.29 to talk to the API server, and a local kind cluster for development so I'm not burning cloud credits during iteration. Bubble Tea's component model fits a game loop surprisingly well — each screen is a Model, and transitions between namespaces are just Cmd messages. The hardest part wasn't the game logic, it was making client-go feel reactive rather than polling, which I'll get into later.
# Minimum viable kind cluster config for local dev
# Saves you from fighting network policies early on
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
networking:
# disableDefaultCNI: false means kindnet handles it — fine for our purposes
podSubnet: "10.244.0.0/16"
For the AI-assisted parts of the build — specifically scaffolding the client-go watcher boilerplate and generating the battle dialog tree logic — I leaned on tools listed in the Best AI Coding Tools in 2026 (thorough Guide). Honest take: it saved me maybe two days on the watcher code alone, though I had to rewrite most of the generated Bubble Tea components because the models still don't fully understand how tea.Cmd channels interact with long-running goroutines. That's a recurring pattern — AI gets you 70% there on boilerplate-heavy Go code, then you're on your own for the subtle concurrency stuff.
Architecture Overview Before You Write a Single Line
The thing that surprised me most when planning this project — the TUI layer is basically free. Bubble Tea's event loop already gives you a state machine with explicit message passing, which maps almost perfectly onto how a game engine works. You're not bolting game logic onto a framework that wasn't designed for it. You're just using the framework correctly and adding a Kubernetes data source.
Here's the mental model I keep in my head when working on this:
- Game Engine layer — a Bubble Tea
Modelstruct that holds all game world state. Player position, NPC positions, encounter flags, dialogue state. Every mutation goes throughUpdate(msg tea.Msg). Nothing touches state directly. - Kubernetes Adapter layer —
client-goinformers that watch pods, deployments, and nodes. These push events into the Bubble Tea program viaprogram.Send(). The informer cache is your source of truth for cluster state; never query the API server in the render path. - Renderer layer —
lipglossfor layout and styles, plus a sprite lookup table that maps ASCII art frames to game entities. Pod = wild Pokémon. Node = gym leader. Deployment = trainer battle.
Why not Ebiten? I evaluated it for about two days and the answer is simple: Ebiten renders to a pixel canvas, which means you're fighting the terminal emulator the whole time. You'd need a separate terminal rendering backend, you lose native copy/paste and resize events for free, and your deployment story becomes "build a binary that needs a graphics context." Bubble Tea gives you a resize event, raw keyboard input, and a clean View() string contract out of the box. The terminal IS the canvas.
The data flow is one-way and that's intentional. Kubeconfig gets loaded at startup with clientcmd.BuildConfigFromFlags, you spin up informers for the resource types you care about, and those informers populate a local cache. Your game tick reads from that cache — never from the network. The game world state is a struct that gets rebuilt each tick from the cache plus whatever player input arrived. Then View() renders that struct to a string. Any mutation to cluster state (scaling a deployment, deleting a pod as a "battle win") goes through a separate goroutine that calls the API and sends a result message back.
// internal/k8s/watcher.go
func StartPodInformer(clientset *kubernetes.Clientset, send func(tea.Msg)) {
factory := informers.NewSharedInformerFactory(clientset, 30*time.Second)
podInformer := factory.Core().V1().Pods().Informer()
podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
pod := obj.(*v1.Pod)
send(PodAddedMsg{Pod: pod}) // dropped into tea.Msg channel
},
DeleteFunc: func(obj interface{}) {
pod := obj.(*v1.Pod)
send(PodDeletedMsg{Pod: pod})
},
})
factory.Start(wait.NeverStop)
factory.WaitForCacheSync(wait.NeverStop)
}
The 60fps myth is worth killing early. You're not writing a real game loop. Bubble Tea doesn't give you one, and you don't want one in a terminal app — blinking at 60fps will cause visible flicker in most terminal emulators and will spike CPU for no reason. What you actually do is issue a tea.Tick(30 * time.Millisecond, ...) command from your Update function to schedule the next tick. That's your heartbeat. 30ms is roughly 33fps, which feels completely smooth for sprite animation and movement. Anything faster and you're just burning cycles re-rendering the same state.
// internal/game/model.go
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case TickMsg:
m = m.advanceFrame() // increment sprite animation counter
m = m.applyPendingEvents() // drain the k8s event queue into world state
return m, tea.Tick(30*time.Millisecond, func(t time.Time) tea.Msg {
return TickMsg{Time: t}
})
case PodAddedMsg:
m.pendingEvents = append(m.pendingEvents, msg)
return m, nil // don't process mid-tick — queue it
}
return m, nil
}
For repo structure, keep the Kubernetes plumbing strictly separate from game logic. The game package should be able to compile without a running cluster — that's how you write unit tests for movement logic and battle state without mocking a Kubernetes API server. I landed on this layout after refactoring once when things got tangled:
project-yellow-olive/
├── cmd/
│ └── yellowolive/
│ └── main.go # wires k8s + game + tea.Program together
├── internal/
│ ├── game/
│ │ ├── model.go # tea.Model implementation, all game state
│ │ ├── battle.go # encounter logic, "trainer" = deployment
│ │ └── world.go # map tiles, player position, NPC grid
│ ├── k8s/
│ │ ├── watcher.go # informer setup, sends tea.Msg events
│ │ ├── actions.go # scale/delete operations (write path)
│ │ └── mapper.go # Pod → GameEntity translation
│ └── ui/
│ ├── renderer.go # lipgloss layout composition
│ └── sprites.go # ASCII art frames, animation tick math
└── assets/
└── sprites/
├── pokemon.txt # named blocks: [pikachu-0], [pikachu-1]
└── overworld.txt # tile glyphs for map rendering
The mapper.go file in the k8s package is doing more work than it looks. It's where you decide that a CrashLoopBackOff pod becomes a "fainted" sprite state, a Pending pod becomes an "in egg" state, and a Running pod with high restart count gets a "confused" status effect. That translation layer is what makes the game feel connected to real cluster state rather than just displaying pod names in a fancy box.
Setting Up Your Dev Environment
The dependency story here bites everyone on the first day. k8s.io/client-go and sigs.k8s.io/controller-runtime share a pile of transitive dependencies that don't always agree on versions, and if you go get both in one shot without tidying between them, you'll end up with a go.sum that looks fine but panics at runtime with interface mismatches. Tidy after every add. I mean it.
Start with kind. If you're on macOS with Homebrew: brew install kind. Linux folks can grab the binary directly:
# kind v0.22.0 — don't go older, the node image API changed
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64
chmod +x ./kind && sudo mv ./kind /usr/local/bin/kind
The cluster config matters because you want three worker nodes — one per "route" between towns in the game world. A control-plane node exists too but your game logic ignores it. Here's the exact kind-config.yaml:
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
labels:
olive-route: pallet-to-viridian
- role: worker
labels:
olive-route: viridian-to-pewter
- role: worker
labels:
olive-route: pewter-to-cerulean
kind create cluster --name yellow-olive --config kind-config.yaml
# verify all four nodes are Ready before moving on
kubectl get nodes -o wide
The labels are load-bearing. Later when your TUI queries which pods are running "on a route," it filters by that olive-route label via the client-go informer. Name them something meaningful now or you'll be doing a find-replace across your controller code at 11pm.
Now the Go module. Use Go 1.22+ — the range-over-int syntax and improved loop variable scoping genuinely help when iterating game state. Init the module and then add dependencies one at a time with a tidy pass between each:
go mod init github.com/yourhandle/yellow-olive
# TUI layer first — no k8s deps, clean baseline
go get github.com/charmbracelet/bubbletea@v0.25.0
go get github.com/charmbracelet/lipgloss@v0.10.0
go mod tidy
# Now the k8s client
go get k8s.io/client-go@v0.29.3
go mod tidy
# Check before adding anything else
grep "replace" go.mod
That last grep is the canary. If you see any replace directives appear after tidying, something upstream is pinning a fork and you need to understand why before adding more deps. The common offender is sigs.k8s.io/structured-merge-diff — client-go v0.29.3 wants v4.4.1, but an older controller-runtime will drag in v4.3.x and the apply patch code will fail silently on structured merges. If you actually need controller-runtime (you might, for the reconciler pattern on pod watches), pin it explicitly:
go get sigs.k8s.io/controller-runtime@v0.17.2
go mod tidy
# Make sure these resolve to the same minor
grep "sigs.k8s.io/controller-runtime\|k8s.io/client-go" go.mod
controller-runtime v0.17.x is built against client-go v0.29.x, so that pairing works. The moment you mix a v0.17 controller-runtime with a v0.28 client-go you get subtle watch event decoding bugs that only show up when pods restart — not on initial list. Your go.mod should look roughly like this when the baseline is stable:
module github.com/yourhandle/yellow-olive
go 1.22
require (
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.10.0
k8s.io/client-go v0.29.3
sigs.k8s.io/controller-runtime v0.17.2
)
One more thing before you write a line of game code: set KUBECONFIG explicitly in your shell and verify the context points at your kind cluster, not whatever AWS or GKE context you had active last.
export KUBECONFIG="$(kind get kubeconfig --name yellow-olive 2>/dev/null | head -1 || echo ~/.kube/config)"
kubectl config current-context
# should print: kind-yellow-olive
I've wasted an embarrassing amount of time wondering why my TUI was showing "no pods found" only to realize I was hitting a staging cluster. Pin the context early, pin it in your Makefile, and commit it to muscle memory.
Building the Kubernetes Adapter Layer
The thing that caught me off guard first time I tried polling with kubectl get pods every render tick: GKE rate-limits you hard after a few hundred requests per minute, and even on a local kind cluster you're burning unnecessary CPU on the API server. The informer cache pattern solves this by opening a single watch stream and maintaining a local in-memory state that your game loop reads from. Zero extra API calls per frame. The informer does a full list on startup, then patches its cache using the watch event stream — your TUI just reads the cache like a local map.
Here's the minimal setup I used. You need k8s.io/client-go v0.29+ and a valid kubeconfig. The 30*time.Second resync period means the informer will do a full reconcile every 30s even if no events arrive — useful for catching any edge cases where watch events get dropped:
import (
"context"
"time"
informers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/clientcmd"
)
func buildAdapterLayer(ctx context.Context, kubeconfigPath string) error {
cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
if err != nil {
return fmt.Errorf("loading kubeconfig: %w", err)
}
// Throttle so you don't hammer a remote cluster during startup list phase
cfg.QPS = 20
cfg.Burst = 40
clientset, err := kubernetes.NewForConfig(cfg)
if err != nil {
return err
}
factory := informers.NewSharedInformerFactory(clientset, 30*time.Second)
podInformer := factory.Core().V1().Pods().Informer()
nodeInformer := factory.Core().V1().Nodes().Informer()
podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) { handlePodAdd(obj) },
UpdateFunc: func(old, new interface{}) { handlePodUpdate(old, new) },
DeleteFunc: func(obj interface{}) { handlePodDelete(obj) },
})
nodeInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) { handleNodeAdd(obj) },
DeleteFunc: func(obj interface{}) { handleNodeDelete(obj) },
})
factory.Start(ctx.Done()) // passes the stop channel directly
// Block until both caches are synced before the game loop touches them
if !cache.WaitForCacheSync(ctx.Done(),
podInformer.HasSynced,
nodeInformer.HasSynced,
) {
return fmt.Errorf("caches never synced — cluster unreachable?")
}
return nil
}
The WaitForCacheSync call is non-negotiable. Skip it and your game loop will render against an empty state on startup, which looks like a crash to anyone playing it. I also set QPS: 20, Burst: 40 on the rest config — the default burst of 10 gets exhausted immediately during the initial list call on clusters with hundreds of pods.
Mapping Kubernetes primitives to game concepts is where the fun is. I seed the Pokémon species from the pod's UID using a simple hash — this means the same pod always renders as the same creature, which matters for the "I've seen this one before" feel:
import (
"hash/fnv"
corev1 "k8s.io/api/core/v1"
)
var pokemonNames = []string{
"Bulbasaur", "Charmander", "Squirtle", "Pikachu",
"Meowth", "Psyduck", "Geodude", "Machop",
// extend to taste
}
func podToPokemon(pod *corev1.Pod) Pokemon {
h := fnv.New32a()
h.Write([]byte(string(pod.UID)))
idx := h.Sum32() % uint32(len(pokemonNames))
// HP maps to container restart count — more restarts = more battle-scarred
restarts := int32(0)
for _, cs := range pod.Status.ContainerStatuses {
restarts += cs.RestartCount
}
return Pokemon{
Name: pokemonNames[idx],
PodName: pod.Name,
HP: max(1, 100-int(restarts)*5),
Phase: string(pod.Status.Phase),
}
}
// Node → Gym Leader: name derived from node's role label
func nodeToGymLeader(node *corev1.Node) GymLeader {
role := "worker"
if _, ok := node.Labels["node-role.kubernetes.io/control-plane"]; ok {
role = "master"
}
return GymLeader{
Name: node.Name,
Role: role,
PodCount: 0, // populated separately from lister
}
}
// Namespace maps cleanly to a town — just use the name directly
func namespaceToTown(ns string) Town {
return Town{Name: ns}
}
Context cancellation is where most TUI games leak goroutines. The factory.Start(ctx.Done()) call above handles the informer goroutines cleanly — when the context is cancelled (user hits q in the TUI), the stop channel closes and the informers drain their work queues and exit. The part people miss is their own event handler goroutines. If you're dispatching events onto a channel for the game loop to consume, make sure your goroutine also selects on ctx.Done():
func dispatchEvents(ctx context.Context, events <-chan GameEvent) {
for {
select {
case <-ctx.Done():
// drain remaining events to unblock any senders, then exit
for {
select {
case <-events:
default:
return
}
}
case ev := <-events:
gameState.Apply(ev)
}
}
}
One honest trade-off: the informer approach means you need a real kubeconfig with watch permissions. If you're running in a restricted cluster where watch is blocked on pods (surprisingly common in enterprise environments), you'll fall back to polling anyway. For local dev with kind or k3d this is never an issue, and that's the primary audience for a Pokémon TUI game anyway. I also added a --demo flag that fills the informer cache with fake data so you can develop the UI without a cluster running — the adapter layer stays identical, you just swap the cache population source.
The Bubble Tea State Machine — Your Game Engine
The thing that sold me on Bubble Tea over tview wasn't the pretty color support — it was the forced architecture. tview wants you to set callbacks on widgets and mutate state directly, which means your game logic bleeds into your rendering code within about three hours. Bubble Tea's model/update/view pattern maps onto a game loop almost perfectly: Update() is your frame tick, View() is your render pass, and the model struct is your entire world state. I switched after prototyping the overworld scroll in tview and realizing my cursor position was being tracked in three different places simultaneously.
Game states in Go iota are clean and fast to switch on. I define mine like this:
type GameState int
const (
StateOverworld GameState = iota // walking the node grid
StateBattle // pod resource fight sequence
StateMenu // start/pause screen
StatePodInspect // full pod detail view, like Pokédex entry
)
Each state gets its own branch in Update() and its own render function called from View(). The iota approach over strings means your switch is exhaustive-checkable and the compiler will yell at you if you add a state and forget a case. Don't use strings here — you'll eventually do a typo comparison at 11pm and spend 40 minutes debugging it.
The main model struct does two things at once: it holds all game world data (player position, active nodes, battle state, encounter cooldown) AND it holds a reference to the live Kubernetes cache. Embedding both in one struct sounds messy but it's the correct call — Bubble Tea's contract is that your model is your universe:
type model struct {
state GameState
playerX int
playerY int
worldNodes []WorldNode
battleState *BattleState
// k8s cache — updated via tea.Cmd, never mutated directly
pods []corev1.Pod
nodes []corev1.Node
cacheErr error
lastSync time.Time
}
The rule I enforce: nothing outside of Update() touches this struct. No goroutines writing to it directly. All Kubernetes calls are fired as tea.Cmd and return messages. This is the thing that catches people new to Bubble Tea — Update() must return fast. If you call clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) synchronously inside Update(), your entire TUI will freeze for however long that API call takes. The pattern is:
func fetchPods(clientset *kubernetes.Clientset) tea.Cmd {
return func() tea.Msg {
pods, err := clientset.CoreV1().Pods("").List(
context.Background(),
metav1.ListOptions{},
)
if err != nil {
return podFetchErrMsg{err}
}
return podFetchMsg{pods: pods.Items}
}
}
Bubble Tea runs that function in a goroutine automatically, then delivers the return value as a message to your next Update() call. Your game keeps ticking the whole time.
The world tick command is what drives overworld animation — NPC movement, encounter probability rolls, spawn blinking. The pattern is self-rescheduling: every tick fires the next one:
type worldTickMsg time.Time
func tickWorld() tea.Cmd {
return tea.Tick(300*time.Millisecond, func(t time.Time) tea.Msg {
return worldTickMsg(t)
})
}
// Inside Update(), when you receive worldTickMsg:
case worldTickMsg:
m = m.advanceWorld() // pure function, returns new model
cmds = append(cmds, tickWorld()) // schedule the next tick
300ms gives you a roughly Pokémon Yellow-era feel — slow enough to read, fast enough to feel alive. Drop it to 100ms if you want smoother sprite animation; raise it to 500ms if you're on a low-resource machine and seeing frame-budget pressure.
The gotcha that bit me on init: you need to fire both the world tick AND an initial Kubernetes list call the moment the program starts, but Init() only returns a single tea.Cmd. The answer is tea.Batch, which wraps multiple commands into one:
func (m model) Init() tea.Cmd {
return tea.Batch(
tickWorld(), // start the animation clock
fetchPods(m.clientset), // warm the cache immediately
fetchNodes(m.clientset), // don't wait for first tick to show data
)
}
Without tea.Batch here, I had a 300ms window on startup where the overworld rendered with zero nodes visible — it looked like a bug, not a loading state. Batching all three together means by the time the first frame renders the user can actually see something. tea.Batch accepts variadic tea.Cmd arguments so you can stack as many as you need; just keep the list readable and document why each one belongs at init time versus on-demand.
Drawing the Overworld: ASCII Sprites with Lipgloss
The thing that caught me off guard first wasn't rendering — it was that my sprites looked completely different depending on whether I SSH'd into the dev box or ran locally. Same code, same terminal emulator version, totally different colors. That's the COLORTERM problem, and I'll get to it last because you need to understand the render pipeline before it makes sense.
Embedding Sprite Assets With //go:embed
Store your ASCII sprite files as plain .txt files under assets/sprites/ and pull them in at compile time. No runtime file path juggling, no os.Open, no "works on my machine" deploy failures. Here's the actual pattern I use:
// sprites.go
package assets
import (
"embed"
"strings"
)
//go:embed sprites/*.txt
var SpriteFS embed.FS
func Load(name string) []string {
data, err := SpriteFS.ReadFile("sprites/" + name + ".txt")
if err != nil {
// hard crash — missing sprite is a build-time mistake, not a runtime one
panic("missing sprite: " + name)
}
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
}
Each sprite file is just the raw ASCII frame, one line per row, no trailing newline. A pod sprite might be 4 lines tall, a player character 6. Keep them small — you're tiling these across a terminal grid, not rendering a scene. One gotcha: the glob in //go:embed sprites/*.txt won't recurse. If you want subdirectories for animation frames, you need //go:embed sprites/** with the double-star, or just flatten everything.
The HUD Layout With Lipgloss
I use three lipgloss boxes anchored to fixed positions: top-left for the current namespace name, top-right for node count, and a bottom strip for the event log. The trick is composing them with lipgloss.JoinHorizontal rather than trying to manually pad strings to terminal width — that way resizes don't break alignment.
var (
hudLeft = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(0, 1)
hudRight = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("214")).
Padding(0, 1).
Align(lipgloss.Right)
eventLog = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false). // top border only
BorderForeground(lipgloss.Color("240")).
Foreground(lipgloss.Color("245")).
Width(termWidth)
)
func renderHUD(ns string, nodeCount int, events []string) string {
left := hudLeft.Render("⎈ " + ns)
right := hudRight.Render(fmt.Sprintf("nodes: %d", nodeCount))
gap := termWidth - lipgloss.Width(left) - lipgloss.Width(right)
top := left + strings.Repeat(" ", gap) + right
bottom := eventLog.Render(strings.Join(events[max(0, len(events)-3):], "\n"))
return top + "\n" + bottom
}
The NormalBorder() call with the four boolean args lets you pick which sides render — I only want the top border on the event log strip so it visually separates from the map without boxing it in. One thing the lipgloss docs underexplain: lipgloss.Width() counts visible characters after stripping ANSI codes, which is what you want when calculating gaps. Don't use len() on a styled string unless you enjoy off-by-a-lot alignment bugs.
Viewport Scrolling for the Tile Map
The viewport package from charmbracelet/bubbles handles scrollable content without you managing offsets manually. Initialize it once in your Init or first Update call after you know terminal dimensions:
import "github.com/charmbracelet/bubbles/viewport"
// inside your model struct
type model struct {
vp viewport.Model
mapLines []string
// ...
}
func (m model) Init() tea.Cmd {
m.vp = viewport.New(m.termW, m.termH-6) // subtract HUD rows
m.vp.SetContent(strings.Join(m.mapLines, "\n"))
return nil
}
The viewport takes arrow keys automatically if you pass it tea.KeyMsg events. What it doesn't do is center the viewport on the player character — you handle that yourself by calling m.vp.SetYOffset(playerRow - m.vp.Height/2) whenever the player moves. Clamp that value between 0 and len(mapLines) - m.vp.Height or you'll get blank rows at the edges.
Placing Player and Pods on the Tile Grid
I represent the map as a [][]rune grid, render each cell to a string, then overwrite specific positions with sprite lines. The key insight: sprites are multi-line, so you stamp them row by row at (spriteRow, col) offsets rather than treating them as a single string.
func stampSprite(grid [][]string, sprite []string, row, col int) {
for dy, line := range sprite {
r := row + dy
if r >= len(grid) {
break
}
runes := []rune(line)
for dx, ch := range runes {
c := col + dx
if c >= len(grid[r]) {
break
}
// only overwrite non-space characters so sprites
// don't erase the background tile behind them
if ch != ' ' {
grid[r][c] = string(ch)
}
}
}
}
Pods get stamped at their calculated tile position (I derive row/col from pod index mod map width). The player overwrites last so it's always on top. Apply lipgloss color styles per cell when you serialize the grid to a string — that's cheaper than styling inside the stamp function, which would mess up the ' ' transparency check.
Getting Accurate Terminal Dimensions
Use term.GetSize() from golang.org/x/term rather than lipgloss.Width() for measuring the terminal itself. lipgloss.Width() measures string content, not the terminal window — easy to conflate these and end up with layout that's correct on first render but wrong after resize.
import (
"os"
"golang.org/x/term"
)
func getTermSize() (width, height int) {
w, h, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
// safe fallback for non-TTY environments like CI
return 80, 24
}
return w, h
}
Bubble Tea sends tea.WindowSizeMsg on resize, so wire that into your model to call getTermSize() and reflow your HUD widths. Don't cache the size at startup and forget it — people resize terminals constantly, especially when they're demo-ing your tool.
256-Color vs TrueColor: Test Both Explicitly
Lipgloss automatically degrades color when COLORTERM isn't set to truecolor or 24bit. The bug surface is: you design your sprite palette using hex colors like lipgloss.Color("#FF6B35"), it looks great locally with iTerm2, then on an SSH session where TERM=xterm-256color and COLORTERM is unset, your carefully picked orange becomes the nearest xterm-256 approximation which might be a dirty yellow. Test both explicitly before you commit a color:
# TrueColor mode — what you probably see locally
COLORTERM=truecolor go run ./cmd/yellow-olive
# 256-color fallback — what your users get over SSH
env -u COLORTERM TERM=xterm-256color go run ./cmd/yellow-olive
My fix was switching all sprite accent colors to explicit 256-color palette values (lipgloss.Color("214") for orange, lipgloss.Color("63") for the purple-blue) so the rendering is deterministic regardless of COLORTERM. You lose the exact hex fidelity, but you gain consistency — and frankly the xterm-256 palette has enough range for an 8-bit game aesthetic, which fits the Pokémon Yellow vibe anyway. If you want TrueColor for things like gradient-styled health bars, gate it behind a check: lipgloss.HasDarkBackground() is in the library, and you can check os.Getenv("COLORTERM") yourself to decide which color set to load.
The Battle Screen: kubectl exec as a 'Move'
The battle screen is where this whole project stops being a cute hack and becomes actually useful. I mapped pod metadata directly to battle stats, and the mapping that felt most natural: CPU requests = HP (a pod with 2000m CPU has a big health bar), restart count = weakness multiplier. A pod that's crashed 14 times is clearly running on borrowed time — the UI should communicate that visually before you decide what to do with it.
Triggering the battle screen happens when the player walks into a pod sprite on the overworld. The state machine transitions to StateBattle, and the selected pod's full metadata gets hydrated via a kubectl get pod -o json call you've already cached. The battle UI renders two panels: left shows the "enemy" (the pod) with its stats parsed from pod.Spec.Containers[0].Resources.Requests, right shows your four moves. Here's how the stat parsing looks in practice:
// resources.Requests["cpu"] comes back as a resource.Quantity
// .MilliValue() gives you a stable integer for the HP bar
cpuHP := pod.Spec.Containers[0].Resources.Requests.Cpu().MilliValue()
restartCount := pod.Status.ContainerStatuses[0].RestartCount
// restartCount > 5 renders the weakness indicator red
// restartCount > 20 adds a skull glyph — you're warned
Three of the four moves are clean Bubble Tea commands — fire a tea.Cmd, collect output, render it in a scroll viewport. The fourth move, Exec Shell, is where you hit a hard wall. Bubble Tea owns stdin/stdout. You can't just spawn an interactive shell inside a Cmd goroutine. What you have to do is fully suspend the Bubble Tea program, hand the terminal to kubectl, and then come back. The sequence that actually works:
// In your Update() handler when user picks Exec Shell:
func (m Model) execShell(podName string, namespace string) tea.Cmd {
return func() tea.Msg {
cmd := exec.Command(
"kubectl", "exec", "-it", podName,
"-n", namespace,
"--", "/bin/sh",
)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// This blocks until the user types 'exit'
// Bubble Tea is completely suspended during this time
_ = cmd.Run()
return ExecDoneMsg{}
}
}
The re-entry gotcha burned me for a solid afternoon. When cmd.Run() returns and Bubble Tea resumes, the terminal state is a mess — the shell you were in drew all over the screen, and Bubble Tea tries to render its UI on top of that wreckage. The fix: your ExecDoneMsg handler must return tea.ClearScreen as the first cmd in the returned batch, before anything else renders. If you return your normal re-render logic first, you get one frame of visual garbage that flickers and then disappears — subtle enough to miss in testing, obvious enough to look broken to anyone watching.
case ExecDoneMsg:
// tea.ClearScreen MUST be first — if you swap the order,
// the previous shell output bleeds through on the first frame
return m, tea.Batch(
tea.ClearScreen,
refreshPodStatus(m.selectedPod),
)
The Delete move uses the textinput bubble component for a confirmation prompt — you have to type the pod name exactly to confirm. This isn't just UX theater; typing payments-api-6d8f9b-xk2vp manually makes you pause. Wire it up by storing a textinput.Model inside your battle model and only enabling the delete command when ti.Value() == m.selectedPod.Name. The textinput component handles all the editing, cursor blink, and focus state — you just render it between your "Type pod name to confirm:" label and the action button, and check the value on Enter. One thing I got wrong initially: I forgot to call ti.Focus() when transitioning into the confirm sub-state, so the input field rendered but didn't accept keystrokes. Always call Focus() explicitly.
Wiring a Real Cluster: RBAC and the Least-Privilege Kubeconfig
The thing that bit me hardest when building this wasn't the TUI rendering or the game logic — it was handing the tool a cluster credential and watching it silently over-request permissions. If your Pokémon Yellow Kubernetes game can accidentally kubectl exec into production pods, you've shipped a footgun. Lock it down before you wire up the informer cache.
The ClusterRole You Actually Need
Read-only observation of cluster state requires exactly these verbs on these resources. Not *, not ["get","list","watch","create"] because you copy-pasted from a blog post. Here's the minimal YAML that lets your game poll pods, nodes, namespaces, and events without touching anything:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: yellow-olive-viewer
rules:
- apiGroups: [""]
resources: ["pods", "nodes", "namespaces", "events"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: yellow-olive-viewer-binding
subjects:
- kind: ServiceAccount
name: yellow-olive-sa
namespace: yellow-olive
roleRef:
kind: ClusterRole
name: yellow-olive-viewer
apiGroup: rbac.authorization.k8s.io
Apply this and your game's ServiceAccount can see everything it needs to render the overworld map. What it cannot do is exec into a container, delete anything, or read secrets. That's intentional. The game experience doesn't require mutation — treat the cluster as a read-only data source the same way you'd treat a public API.
Exec Into Pods as a Feature? Namespace-Scope It Tightly
If you want a "battle" mechanic that drops the player into a shell inside a specific pod — which is genuinely fun — resist the urge to add pods/exec to the ClusterRole above. Grant exec in a Role (namespace-scoped, not cluster-scoped) targeting only the namespace you control:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: yellow-olive-exec
namespace: yellow-olive # only this namespace
rules:
- apiGroups: [""]
resources: ["pods/exec", "pods/attach"]
verbs: ["create"] # exec is modeled as a "create" on the subresource
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: yellow-olive-exec-binding
namespace: yellow-olive
subjects:
- kind: ServiceAccount
name: yellow-olive-sa
namespace: yellow-olive
roleRef:
kind: Role
name: yellow-olive-exec
apiGroup: rbac.authorization.k8s.io
This gives exec only inside yellow-olive. If someone hands this tool a kubeconfig pointed at their production cluster with 40 namespaces, the exec mechanic simply fails with a 403 everywhere except your sandbox namespace. That's the right failure mode.
Stop Hardcoding ~/.kube/config
I see this constantly in hobby tools and it breaks the moment someone runs them in CI, inside a container, or with a non-default context. The correct pattern in Go is:
import (
"os"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/rest"
)
func buildConfig() (*rest.Config, error) {
// In-cluster config wins if we're running as a pod
if cfg, err := rest.InClusterConfig(); err == nil {
return cfg, nil
}
// Respect KUBECONFIG env variable — this is what CI systems set
kubeconfig := os.Getenv("KUBECONFIG")
if kubeconfig == "" {
// clientcmd knows the default path; let it decide, don't hardcode
kubeconfig = clientcmd.RecommendedHomeFile
}
return clientcmd.BuildConfigFromFlags("", kubeconfig)
}
The --kubeconfig flag is the other side of this. If your TUI accepts a flag, wire it into the same function: override kubeconfig with the flag value before calling BuildConfigFromFlags. That gives you three sensible layers: in-cluster → env variable → flag/default path. Never bypass this chain with a hardcoded string.
Kind vs GKE/EKS: The Informer Cache TTL Surprise
I built the first version entirely against kind and the informer cache felt rock solid. Then I pointed it at a real GKE cluster and the watch connections started dying silently after 5-10 minutes with no error surfaced to the game loop. The difference is that cloud providers (GKE, EKS, AKS) aggressively terminate idle or long-running watch connections at the load balancer layer — GKE's internal proxy cuts connections around the 30-minute mark, but EKS with its NLB can be much shorter depending on TCP idle timeout config. kind has no such middlebox, so you never see this in local dev.
The fix is ResyncPeriod on your informer factory. This forces a full re-list and re-sync on a schedule, so even if a watch connection dies, your cache catches up on the next resync cycle:
import (
"time"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
)
func newInformerFactory(client kubernetes.Interface) informers.SharedInformerFactory {
return informers.NewSharedInformerFactory(
client,
5*time.Minute, // ResyncPeriod: relist every 5 min as a safety net
)
}
// Then set up your informers as usual
podInformer := factory.Core().V1().Pods().Informer()
nodeInformer := factory.Core().V1().Nodes().Informer()
Five minutes is the sweet spot I've landed on. One minute hammers the API server unnecessarily — especially if you're on a cluster with hundreds of pods. Ten minutes is too long; you can miss state changes that affect the game's visual accuracy. The resync doesn't replace the watch — it backs it up. When the watch is healthy you get real-time events; when the connection drops, you drift for at most 5 minutes before a full relist snaps everything back into consistency. On GKE, also set QPS: 20 and Burst: 30 on your rest.Config — the default QPS: 5 causes informer initialization to stall visibly on clusters with 50+ nodes.
Packaging and Distribution
The silent failure that will waste your afternoon: cross-compiling for Windows from a Mac or Linux host without disabling CGO. The build succeeds, you get a binary, it looks fine — but it either panics at runtime or produces a zero-byte artifact depending on your toolchain version. Set this and forget it:
# .goreleaser.yaml
builds:
- id: yellow-olive
main: ./cmd/yellow-olive
binary: yellow-olive
env:
- CGO_ENABLED=0 # without this, Windows cross-compile silently produces garbage
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w # strip debug info and symbol table — shaves ~30% off binary size
For local dev builds the manual command is straightforward. The -s -w ldflags combo is non-negotiable for distribution — a TUI game binary with debug info bloats unnecessarily and there's no debugging benefit for your end users:
go build -ldflags='-s -w' -o yellow-olive ./cmd/yellow-olive
# verify the size delta yourself
go build -o yellow-olive-debug ./cmd/yellow-olive && ls -lh yellow-olive*
# yellow-olive ~8MB (stripped)
# yellow-olive-debug ~12MB (with symbols)
The Makefile cross-compile matrix is useful when you want quick local test artifacts without goreleaser. I keep this around specifically for smoke-testing the Windows build before tagging a release:
BINARY=yellow-olive
LDFLAGS=-ldflags='-s -w'
PLATFORMS=darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
.PHONY: build-all
build-all:
$(foreach platform,$(PLATFORMS), \
$(eval GOOS=$(word 1,$(subst /, ,$(platform)))) \
$(eval GOARCH=$(word 2,$(subst /, ,$(platform)))) \
$(eval EXT=$(if $(filter windows,$(GOOS)),.exe,)) \
GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) \
-o dist/$(BINARY)-$(GOOS)-$(GOARCH)$(EXT) ./cmd/yellow-olive ;)
The GitHub Actions release workflow triggers on any v* tag push. goreleaser v1.24 handles the multi-arch build matrix, checksums, and GitHub release upload in one shot. The thing that catches people out is the GITHUB_TOKEN permissions — your workflow needs contents: write explicitly, the default read-only token will fail silently at the upload step:
# .github/workflows/release.yaml
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write # required for goreleaser to create the release and upload artifacts
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # goreleaser needs full git history for changelog generation
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: goreleaser/goreleaser-action@v5
with:
version: v1.24.0
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The Homebrew tap is the easiest part of this whole pipeline, and it's genuinely simpler than most people expect because yellow-olive has zero native deps. Pure Go means no depends_on blocks, no bottle do complexity. After goreleaser uploads the release, grab the Darwin amd64 tarball URL and its sha256 from the release artifacts, then create Formula/yellow-olive.rb in a homebrew-tap repo:
class YellowOlive < Formula
desc "Pokémon Yellow inspired Kubernetes TUI game"
homepage "https://github.com/yourname/yellow-olive"
version "1.0.0"
on_macos do
on_arm do
url "https://github.com/yourname/yellow-olive/releases/download/v1.0.0/yellow-olive_darwin_arm64.tar.gz"
sha256 "abc123..." # copy from the goreleaser-generated checksums.txt
end
on_intel do
url "https://github.com/yourname/yellow-olive/releases/download/v1.0.0/yellow-olive_darwin_amd64.tar.gz"
sha256 "def456..."
end
end
def install
bin.install "yellow-olive"
end
end
You can automate the sha256 and URL updates with goreleaser's built-in Homebrew tap support — add a brews: block to .goreleaser.yaml pointing at your tap repo and it'll open a PR automatically on release. The one gotcha there: goreleaser needs a separate token with write access to the tap repo, GITHUB_TOKEN only covers the source repo. Store it as HOMEBREW_TAP_TOKEN in your secrets and pass it through the action's env block.
Gotchas I Hit That the Docs Don't Mention
The one that burned me hardest was the informer cache sync. You call factory.Start(stopCh), immediately try to list pods, and get back an empty slice. No error. No panic. Just silence. I spent two hours convinced my label selectors were wrong before I realized the cache hadn't populated yet. The fix is mandatory and non-negotiable:
factory := informers.NewSharedInformerFactoryWithOptions(
clientset,
30*time.Second,
informers.WithNamespace("default"),
)
podInformer := factory.Core().V1().Pods()
factory.Start(stopCh)
// This blocks until all registered informers have synced.
// Without this, cache reads return empty results on startup.
if ok := cache.WaitForCacheSync(stopCh, podInformer.Informer().HasSynced); !ok {
return fmt.Errorf("timed out waiting for caches to sync")
}
// Safe to read now
pods, err := podInformer.Lister().Pods("default").List(labels.Everything())
The docs mention WaitForCacheSync once, buried in the informer overview. What they don't say is that there's a race window between factory.Start() and actual sync completion that can last 2–5 seconds on a real cluster. In a TUI game where you're kicking off initialization during the title screen animation, that's exactly long enough to make everything look broken.
Bubble Tea's alt-screen mode does not play well with subprocesses. The moment you run something like kubectl exec or any command that takes over stdin/stdout, the terminal state gets shredded — you'll come back to garbled output, invisible cursor, and key events firing twice. The correct pattern is to bracket your subprocess call:
// Before spawning kubectl exec or any raw terminal subprocess
if err := p.ReleaseTerminal(); err != nil {
return err
}
cmd := exec.Command("kubectl", "exec", "-it", podName, "--", "/bin/sh")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
// log but don't fatal — the shell exit is often code 1
log.Println("exec exited:", err)
}
// Restore Bubble Tea's raw mode and re-enter alt-screen
if err := p.RestoreTerminal(); err != nil {
return err
}
The lipgloss rendering width issue with emoji and box-drawing characters is genuinely annoying because it's terminal-dependent. On macOS Terminal.app, a "wide" box-drawing sprite might report a display width of N but render as N+1 columns, shifting everything to the right. iTerm2 handles it correctly. Alacritty depends on your font and the version of the wcwidth table it compiled against. I ended up padding sprite cells with a trailing space and using lipgloss.Width() to measure rendered strings rather than trusting raw rune counts. If you're using the mattn/go-runewidth library directly, call runewidth.SetEastAsianWidth(false) early in main() — the default east-asian ambiguous width handling is what causes the off-by-one on most western terminals.
The kind cluster NotReady window is real and annoying specifically in a game context where you want immediate feedback. Node transitions from NotReady to Ready take ~20–35 seconds after kind create cluster returns. If your game reads node status at startup to draw the "town map," you'll render a ghost town. I added a simple readiness gate:
func waitForNodes(ctx context.Context, cs *kubernetes.Clientset) error {
return wait.PollUntilContextTimeout(ctx, 2*time.Second, 60*time.Second, true,
func(ctx context.Context) (bool, error) {
nodes, err := cs.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if err != nil {
return false, nil // retry on transient errors
}
for _, n := range nodes.Items {
for _, c := range n.Status.Conditions {
if c.Type == corev1.NodeReady && c.Status != corev1.ConditionTrue {
return false, nil // at least one node still NotReady
}
}
}
return len(nodes.Items) > 0, nil
},
)
}
Run this behind a loading spinner during the "PROF. OAK is booting the cluster…" screen. Users expect a startup delay in a Pokémon-style game anyway — use it.
The embed symlink issue is subtle. You have a assets/sprites/ directory and you've symlinked assets/gen1/ to it for organizational reasons. //go:embed assets/gen1 silently embeds nothing — no error, no warning, the directory just doesn't exist at runtime. Go's embed implementation explicitly skips symlinks, and the only mention of this is a single sentence in the go help embedpattern output that most people never read. Keep every sprite file as a real path in your repo. If you need to share assets, use a Go build tag + a single real directory, not filesystem symlinks.
What to Build Next: Multiplayer and Real Cluster Events
The most unexpectedly satisfying part of building this whole thing was realizing that Kubernetes already generates a live event stream that maps almost perfectly to classic RPG encounter mechanics. You don't need to fake anything. Run kubectl get events --watch for five minutes on a real cluster and you'll see warning events firing constantly — BackOff, OOMKilled, Failed, Pulled. That's your encounter table, already populated.
Using Kubernetes Watch Events as Random Encounters
The client-go watch API gives you a channel of events you can treat exactly like a random encounter roll. A Running pod event is a common Rattata — ignore it or fight it for minimal XP. A CrashLoopBackOff is your rare shiny: flash the screen differently, play a different music track (yes, you can embed MIDI-style beeps in a TUI with the beep library), and reward more XP for resolving it. The logic to wire this up is maybe 40 lines:
watcher, err := clientset.CoreV1().Events(namespace).Watch(ctx, metav1.ListOptions{})
if err != nil {
return err
}
for event := range watcher.ResultChan() {
e, ok := event.Object.(*corev1.Event)
if !ok {
continue
}
// CrashLoopBackOff = shiny encounter rate ~1/8 like the original
if e.Reason == "BackOff" && strings.Contains(e.Message, "CrashLoopBackOff") {
triggerShinyEncounter(e.InvolvedObject.Name)
} else if e.Reason == "Failed" || e.Reason == "OOMKilling" {
triggerNormalEncounter(e.Reason, e.InvolvedObject.Name)
}
}
One gotcha: the watch stream will flood you on a busy cluster. I throttle encounters with a simple cooldown map — store the last encounter time per namespace and skip events that fire within 30 seconds of the previous one. Otherwise you'll be stuck in perpetual battle during a deployment rollout.
Multiplayer via Pod Annotations (Yes, Really)
Storing player positions as pod annotations on a well-known pod (I used a dedicated yellow-olive-session pod that the game creates on startup) actually works for a two-player demo. Both players share a kubeconfig pointing at the same cluster, each terminal runs the TUI, and they PATCH the pod annotations to broadcast position:
# Player 1 writes their position
kubectl annotate pod yellow-olive-session \
game.yellowolive/player1-pos='{"x":4,"y":7,"map":"overworld"}' \
--overwrite
# Player 2 reads it on their next tick by watching the pod object
# client-go re-fetches the pod every 500ms and renders player 2's sprite
This is absurd infrastructure for a game and that's exactly why it's fun. The latency is fine for a turn-based game — you're looking at 50-200ms round trips to the API server depending on where your cluster lives. The real problem is conflict resolution: if both players move at the same time and PATCH simultaneously, the last write wins and one player teleports. I never bothered fixing it. It's a feature.
HPA Scale Events as Evolution Animations
HPA scale-up events are legitimately exciting to watch in a cluster — you can hook them the same way via the events watch, filtering for SuccessfulRescale reason. Map replica count to evolution stage: 1 replica = Bulbasaur, 3 = Ivysaur, 5+ = Venusaur. When the HPA fires and crosses a threshold, trigger a full-screen evolution animation using tcell's cell-by-cell drawing. I used a 500ms frame timer and drew the ASCII sprite expanding outward from the center. It takes maybe 3 seconds total and players lose their minds when it fires mid-gameplay during an actual load test.
Leaderboard via ConfigMap
Storing the leaderboard in a ConfigMap is the most Kubernetes-brained thing in this entire project and I will not apologize for it:
// On game over, read existing scores, append, write back
cm, err := clientset.CoreV1().ConfigMaps("yellow-olive").
Get(ctx, "leaderboard", metav1.GetOptions{})
var scores []Score
json.Unmarshal([]byte(cm.Data["scores"]), &scores)
scores = append(scores, Score{Player: playerName, Points: finalScore, Timestamp: time.Now()})
sort.Slice(scores, func(i, j int) bool { return scores[i].Points > scores[j].Points })
if len(scores) > 10 { scores = scores[:10] } // top 10 only
encoded, _ := json.Marshal(scores)
cm.Data["scores"] = string(encoded)
clientset.CoreV1().ConfigMaps("yellow-olive").Update(ctx, cm, metav1.UpdateOptions{})
ResourceVersion conflicts will bite you here if two players finish simultaneously — wrap the Update in a retry loop that re-fetches on 409 Conflict. The ConfigMap persists across game sessions as long as the namespace exists, which means your cluster remembers your high score longer than most side projects stay alive.
The Honest Limit of This Project
I want to be direct about this: k9s still wins for actual incident response, and nothing about Yellow Olive changes that. k9s gives you log streaming, port-forward, exec into pods, and resource editing in a TUI that's been battle-tested. Yellow Olive gives you a Pokémon battle when your deployment crashes. These are not competing products. Where this project genuinely earns its place is as a learning tool — I've watched junior engineers actually internalize what a CrashLoopBackOff is because they fought it as a "boss encounter" and had to read the event message to pick the right "move" (delete pod, check logs, describe node). That stickiness is real. Use it for onboarding, use it for conference demos, use it for a Friday afternoon when the team needs to remember why they got into this work. Just keep k9s in another terminal tab.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)