One project, six ways to read the engineering.

Corp Tower is a 3-player real-time tower-building game. Every card below answers one question — why this way, and not the other way — from a different role's point of view. Every language here is one I can read fluently: Node.js on the server, GDScript in the client. When a coding agent can't solve something, I have to be able to take over — that's a deliberate choice against picking whatever's fastest to generate.

Node.js · GDScript / Godot · Redis · Terraform · K3s · EKS · GitHub Actions · Cloudflare

Corp Tower deployment topology A single Godot client connects over WebSocket to one of three independently deployed environments running the same container image: K3s production and test, running continuously; EKS, a session-scoped validation stack brought up on demand and torn down after; and a physical backup machine running dev instances plus the always-on public demo. K3s uses an in-cluster Redis, EKS uses ElastiCache over rediss, and the backup machine runs no Redis at all. Player Godot client Android / Web wss:// K3s — production + test EC2, Caddy gateway, always on Redis: in-cluster EKS — validation ALB, managed nodes, session-scoped Redis: ElastiCache (rediss://) Physical backup — dev + demo Cloudflare Tunnel, always on Redis: none (in-memory) same container image, every environment
Play it Watch the cluster — coming soon

The demo fills empty seats with bots so a lone visitor still gets a full round. The real game is three human players.

Cloud

The game runs on two completely different setups — a serious cloud one for production, and a single computer at home for the demo you just played. Same game, deliberately different foundations, and the reason why is the whole point.

Decision. Production on EKS: ALB with an ACM wildcard, target groups bound to node ports via the autoscaling group, managed nodes in private subnets, ElastiCache over rediss://. Dev and demo on one Linux box behind a Cloudflare Tunnel — no load balancer, no NAT, no managed cache. Identical container image, ports, and manifests.

Instead of one topology for both: an always-on cloud dev cluster costs money monthly for an audience of one and still isn’t production; running production the home way means one machine between the game and the internet. Also rejected: the AWS Load Balancer Controller — binding target groups straight to the ASG removes a component to install, upgrade, and grant IAM, in exchange for static node ports. Worth it at two services.

For the operator (permanent environment at zero marginal cost, production-grade one only while in use) and the player (a link that’s always up). Buy environment parity where it changes behavior; skip it where it only costs money.

Proof. Health matcher 426, because ws answers a plain GET / with Upgrade Required. Node security group carries its own control-plane and self-referencing rules via a launch template that opts out of EKS’s default wiring. Auto-destroy at 18:00 UTC daily because AWS Budgets alerts lag 8–24h — the alarm can’t be the control. Gateway TLS state externalised to R2 after ephemeral disks re-requested certificates until Let’s Encrypt’s rate limit stopped it.

DevOps

I run every environment of this project alone. So I built the parts of a team I don't have — the person who does releases, the person who checks before you break something, and the person who remembers the steps.

Decision. Encode three absent roles as CI jobs rather than as discipline.

  • The release engineer. One workflow takes Android from source to store: verified art, version code resolved from the live Play API, signed build, five artifact checks, upload, then a read-back to confirm the store lists what was just sent.
  • The reviewer. Nothing destructive runs unchallenged. A deploy fails before any build if infra code differs from what was last applied. An apply hard-fails if the plan contains any delete or replace. A destroy cross-checks leftovers against the live EC2 API. Cleanup needs a typed phrase naming the exact target.
  • The runbook. Reusable cores with thin dispatchers; shared composite actions for auth, Terraform setup, art fetch, and web build; deploy-time generated manifests so nobody hand-edits an image tag; diagnose workflows that answer “is it healthy” without SSH.

Instead of the two normal answers. Copy-pasted per-target workflows are fastest to write and drift until prod and test differ for reasons nobody remembers. One monolithic workflow with conditionals is unreadable and makes deploying a single target risky. Also rejected: trusting stored status flags and the AWS tagging API — both are checked against live state instead, because both have lied.

For the operator. Automation removes what’s repetitive; guardrails remove what’s irreversible. Automating a daily task saves time; automating a rare one — like a release — prevents an error that would otherwise reach users, because infrequency means muscle memory never forms.

Proof. Version codes derived by reading every existing track rather than incrementing a counter. Art verified download → hash → extract → file count → sentinel files, each failing the build closed, with the hash-skipping override marked never-for-release in the workflow itself. Push-triggered deploys skip an instance whose container isn’t running, so a routine commit can’t silently restart something that was deliberately taken down. DNS cutover verified through the Cloudflare API rather than dig, because a proxied record never exposes a literal CNAME — the obvious check would report every success as a failure.

QA

I work the way a QA engineer works with a developer — except the developer is an AI agent. I define what "done" means before work starts, it builds, I test and send it back with specifics, and we loop until nothing is outstanding. Only then does the documentation get updated.

Decision. A real QA↔developer loop with an agent in the implementer’s seat. Same handoffs, same definition of done, same rejection path an org runs through a ticket system.

Instead of treating the agent as an oracle (fast, and nothing is judged against a requirement) or as autocomplete (safe, and it collapses back to writing everything yourself). Also rejected: writing docs alongside the code, which documents intentions rather than what shipped.

For the operator: a definition of done enforced by process, not by work feeling finished. The person who implemented something is the worst-placed person to judge whether it meets the requirement — and that stays true when the implementer is a model.

Proof — the artifacts that make this a process, not a posture. Documentation updates run only after a goal is confirmed reached, never speculatively, replacing prose rather than appending. CI gates are the automated acceptance criteria cleared before a human looks. Coverage gaps are written down as a known-issues register rather than quietly omitted. Invalid measurements are recorded so nobody re-runs them — don’t calibrate against collapse rate; the bots avoid collapsing columns, so it reads ~0% across wildly different settings. And the multi-pod regression test was accepted only after being run against the pre-fix code and watched to fail.

Backend

The server decides everything; players' phones only draw the result. That's what lets someone lose signal on the bus, come back, and still be in the same game.

Decision. Server-authoritative, with room state in Redis so any worker can serve any player and any pod can recover a room it didn’t create. Node.js, because I read it fluently.

Instead of host-authoritative — cheaper, common in small multiplayer, and it loses twice here: the host’s disconnect ends everyone’s game on a title played by three people on mobile, and the design is a scoreboard with selfish-cooperation tension, so client-computed scoring makes the whole design decorative. Also rejected: sticky sessions pinning a room to one pod, which turns any restart into a lost room.

For the player. Reconnect inside the TTL resumes the same slot in the same room, rebuilt by whichever worker answers.

Proof. Room formation is serialized by a Redis lock while enqueueing is deliberately left unlocked — that’s the actual race window, and locking it would cost throughput for nothing. Players whose room was formed by another pod are handed to the pod holding their live socket. Stability is a pure function, so the engine, the bots, and the offline simulator grade a tower with identical math.

Frontend

The game's rules are kept completely separate from its graphics, so the part that has to be correct can be checked in seconds without opening the game.

Decision. Placement resolution, brick anchors, and the collapse simulation live in plain classes with no scene dependency. Scenes draw; helpers decide. The playable grid comes from the server, never hardcoded. GDScript, because I read it fluently.

Instead of putting the logic in the node that draws it — the natural Godot idiom, fewer files, everything already in scope. It loses because scene-mounted logic needs a mounted scene to exercise, making the highest-consequence code the hardest to verify. Hardcoding the grid loses separately: the play area scales with level, so a client constant is a desync waiting for a level.

For the player (a ghost never lands where the server scores differently) and the maintainer (change tuning, know in seconds whether placement still holds).

Proof. Nineteen scene-free tests pin that no resolved column ever lets a piece leave the play area, including when it widens mid-game. The brick-face key is asserted to match the server’s field name, because a silent mismatch removes every face and nothing else would catch it. Stated boundary: a drag-state bug passed all of them and was caught only by rendering the field to an image — this design verifies the math, not what’s drawn.

AI

I built a small, hand-written knowledge system so an AI agent can work on this codebase without re-reading it every time. It's deliberately simple — the industry-standard version would cost more and explain less at this size.

Decision. File-based, human-authored knowledge base with tiered retrieval and an explicit task router: one entry document, a table mapping task type to the one or two documents it needs, an ignore map, one-owning-doc rule, stated token budget. No embeddings, no vector store. Source carries no explanatory comments by rule — context lives in the owning document.

Instead of enterprise-style RAG. Rejected on sizing, not principle: ~1.6k lines across thirteen documents. A router that names the right document beats similarity search at that scale, costs nothing to run, and is auditable — when retrieval goes wrong the reason is legible in a table rather than buried in an embedding space. Retrieval here is a curation problem, not a search problem.

For the operator: a common task loads the entry document plus one or two others and runs zero repo-wide searches — a stated, measurable cost per task.

Still evolving, by design. This is the correctly-sized form for today’s corpus, not a finished position. As it grows and local models become practical to run, routing and retrieval move onto a local LLM — offline, no per-token cost — and at that size embeddings start earning their complexity. The swap is a change of retrieval mechanism, not a rewrite: the documents stay the source of truth, and the router table is exactly the ground truth a retrieval evaluation would need.