Coding is Design

Ever since I can remember, I’ve had a habit of writing my thoughts down. Maybe it was how I felt about something, things I needed to get done, personal goals, plans for the future, and whatever else was on my mind. The act of writing my thoughts was similar to the act of teaching, only your audience is your current and future self. Most programmers I’ve ever known use the rubber duck method; sometimes the rubber duck is another programmer, other times it’s a literal rubber duck, but the reason for this is the same; explaining something to someone or something helps them solidify their thoughts, spot holes in their understanding, and quickly pivot before they commit to something.

The problem with extending this to programming however is that what may sound perfectly reasonable in theory, may be terrible in practice. For example, years ago the idea of a generalized ability system in a game project I was working on sounded great! Everything would share the same code. I could reuse the dash ability for both the player character and enemies. I spent hours thinking about it and felt confident that my design was brilliant.

About a month later “why the hell do I even have this system if I have to constantly create different code paths because the player character and enemies have fundamental different requirements?”. Sure, in theory a dash is a dash, but then you realize “oh wait, the player dashes 3x in a row before a cooldown but if they upgrade their stats, they can dash 4x in a row over a greater distance, and then the cooldown a can also be modified by this stat..”. Meanwhile the enemy code “the player is 8 meters away, figure out the required force based on the distance, then execute it”.

Despite all of the thinking I did, the real requirements weren’t clear until I encountered friction in the code.

Prototyping

I learned my lesson. I realized that how I should have tackled the ability system was to start with a hypothesis about how it could work and then implemented a bear-bones prototype to see if it’d work in practice. Now of course, finding the boundary of where the prototype should stop is tricky because I previously didn’t really run into the friction until a month later once I began adding new requirements for how the player character’s dash should behave, but had I started out by prototyping the player character behavior instead of trying to tackle multiple things at once, it would have became fairly evident that the same logic wasn’t going to extend well to enemies.

I think there are at least a few types of prototypes:

  1. Feature Prototypes: such as “give the player the ability to push objects”. Narrowly scoped validation or exploratory tests.
  2. Systems Prototypes: such as “I need a dialog that highlights characters their turn to speak is triggered”.
  3. Architectural Prototypes: such as “the player progresses by collecting souls and spending them at a merchant, I think I’ll need a simple inventory system to keep the souls count, a trigger volume that reports what souls are nearby, an input to trigger collecting the souls, a ui...”.

All of these things can be quickly thrown together, and for the more complex architectural prototypes, you don’t really need real systems, just enough to validate the model. The goal isn’t to create a perfect, final solution from the start, it’s to see where the problems may be without heavily committing to any particular direction, only to find that you’ve just waisted potentially weeks of time, effort, and perhaps money.

I plan to keep writing code

What works for some types of software fails miserable for other types of software. CRUD applications and backends are a solved problem. There’s only so many ways to structure a simple HTTP server, read from a database and respond with some JSON data. Reasoning about a simple CRUD API is a much easier task than reasoning about something that runs 120x per second over potentially hours on end, while interacting with dozens of other systems, all while handling thousands of state mutations per second (maybe even per frame).

Requirements are drastically different as well depending on the software you’re developing. There’s a huge difference between “Create a user profile system” and “Create a character controller”. The former has a relatively small cost of getting it wrong; you change a few lines in React, edit your DB schema, update the endpoint and the query, and you’re done. Of course this isn’t always so simple, but I’m referring to your standard, low traffic standard CRUD web app.

When the developers of Photoshop set out to create the first version, they were looking at a massive challenge even in the 90’s, hell, especially because it was the 90’s and access to infinite open source projects didn’t yet exist. It’s also not like there were a dozen competitors out there either. I could easily be wrong in this assumption, but I can’t imagine that they knew 100% from the start, exactly what features they were going to eventually end up with. Even by today’s standards, what they accomplished back then would still be a massive undertaking for any development team.

What I can be fairly certain of is that they didn’t set out to create a one off product, never to be touched again. They certainly had a huge road map for features to come over time. This would have also certainly required a huge effort in creating a massively robust, stable core architecture in which everything down the road could be built on top of.

My point is, your ability to create a specification depends greatly on knowing what the hell it is you’re trying to build in the first place. The more simple and straightforward the project is, the more simple and straightforward the specifications become. It doesn’t take days, weeks, or months of research and discovery to prompt an LLM to generate some react components and a user profile API.

I’m not conflating features and requirements with software architecture either; they go hand-in-hand. The architecture for a compiler probably wouldn’t make sense as-is being used as the architecture for a financial banking system. The requirements inform the engineer what the architecture needs to be capable of, while the code itself informs us what structure the architecture needs.

I personally only arrive at the architecture after having written the code as part of the design process. The more complex and uncertain the problem, the less reasonable it is to expect the complete architecture to precede implementation, because implementation itself produces information about the problem.