
Human-Friendly Game Architecture
Before we begin, let me tell you who this article is for:
- You've written some game code
- You've felt your code was a mess but didn't know how to fix it
It's not for:
- People with no programming experience
- People who haven't written much code
- People who only let AI write code and suffer debugging it
I know many people don't write code themselves anymore. But I'm still writing this because:
- Even if you just feed it to your AI, maybe it'll generate less chaotic code.
- I genuinely struggled with this for a long time.
If you're "All in Claude" — this article's target audience is your Claude. Stuff it into its system prompt.
Let's get started.
Data Management
Games have a lot of data. I'm talking about static data. Where to put it is a real question. Here's the answer.
> Newbie tip:
> Static data = data that never changes. e.g. "Pikachu's base HP is 35", "Fireball deals 25 damage". The player's current HP changes, so that's not static data.
If you have very little data: hardcode it.
Yes, just hardcode it. Don't go building a data-driven pipeline for three characters' stats just because you read two blog posts about architecture.
Keep it simple — the smaller your tech stack, the easier it is to maintain. A constant object, imported and used directly.
If you have lots of data: write it in Excel, then export to a common format.
I know there are libraries to read Excel directly, but it's always a bad idea. I genuinely can't find a single benefit to reading .xlsx files directly. They're larger, their structure is less predictable, and they're not git-friendly.
Just have your code agent write an export script that outputs YAML or JSON, with type checking and foreign key validation.
If that sounds like too much work, don't worry — someone's already done it:
Sure, adopting a new tool means learning overhead, but you can always ask your code agent.
My recommended export format is YAML, because YAML can be handwritten. When you have data that just can't be expressed in a spreadsheet, write it by hand without any special workaround.
Don't chase perfect data-driven design.
This is the #1 dumb trap for indie developers, bar none.
Someone always has an epiphany — "data-driven is the way!" — and starts cramming all game logic into JSON files:
- Behavior trees in data
- Script callbacks in data
- Conditional branches in data
Eventually, the data files become... a language. An awkward, untyped, Turing-incomplete language that only you can read.
Congratulations, you've invented a new language — and it's worse than every existing one.
The truth is: you need a real programming language. One with a type system, a debugger, an LSP, and a million similar questions answered on Stack Overflow. Why would you trade all that for JSON?
Besides, you're already using a programming language to make your game.
Large, structured, spreadsheet-friendly data — enemy stats, item info, skill data — naturally fits in tables. Spreadsheets genuinely work, and using Excel is great for your mental health.
For complex, branching, heterogeneous data — skill effects are the classic example. Fireball is "damage + burn", Blink is "teleport + invincibility frames", Thorns Shield is "reflect damage while active". Their logic structures are completely different. Forcing them into the same table abstraction is a disaster. In these cases, write code. Use a string tag in the data to identify the effect type and dispatch with a simple switch.
DI
I use DI, but only as a service locator, and almost exclusively with singletons.
> Newbie tip:
> DI (Dependency Injection) sounds scary, but it just means I don't new things myself — they're passed in from outside.
> You don't need a DI library; simply passing dependencies as function parameters is already DI.
> But many beginners, trying to avoid singletons, pass AudioManager from the main menu through five layers of functions to the battle scene — which is worse than a singleton.
I know "singleton" sounds unhealthy. But games genuinely have a bunch of singletons: resource manager, audio manager, save manager. They're naturally globally unique — the entire game lifecycle needs exactly one instance. Manage them with a DI container.
class CombatManager {
val mobTemplates: DataTable<MobTemplate> by inject()
}
Inject anywhere, anytime. No long constructor parameter lists. No threading AudioManager through five intermediate layers.
Sounds good, but you might think — why not just:
object Global {
var mobTemplates: DataTable<MobTemplate>
}
Fair point. Here's my reasoning:
- If your language supports nullable types, you lose null safety.
- You lose some advanced DI container features, like keyed injection or factories.
Of course, don't abuse it. I'm not putting an enemy's health controller in DI. That's going too far.
State Management
Model your game as a state machine.
I said aggressively model it as a state machine.
But before you go search for "game state machine framework" — you don't need an ultimate library with visual editing, transition guards, and hierarchical substates. You just need mutually exclusive states expressed through the type system:
type PlayerState =
| { kind: "Idle"; mood: "Joy" | "Sad" }
| { kind: "Moving"; direction: Vector2; speed: number }
| { kind: "Attacking"; comboStep: number }
Tagged union, sum type — call it what you want. When you miss a branch in switch (state.kind), the compiler immediately warns you. This exhaustiveness check is the best weapon against state bugs, more reliable than any state machine library, with zero runtime overhead and zero learning cost.
isJumping && isSliding both true? That's a compile error with this approach. Mysterious freezes, animation glitches, unresponsive inputs — many of them just vanish.
UI too. Main menu, settings, in-game HUD, pause menu — all managed by a single UIState union type. No screen stack needed.
Functional Programming
You don't need to become a functional programming zealot. Just write pure functions when you can. If you have zero FP background:
Pure function: same input always produces the same output; doesn't modify or depend on external state.
The most textbook example in games is damage calculation. Input attacker stats, defender stats, skill params — output damage value and a list of side effects. This function shouldn't read a global RNG (pass RNG as a parameter instead), and it shouldn't directly modify the target's HP. It just computes the result. Applying damage, playing effects, showing numbers — that's the caller's job.
Immutable data structures? Yes, absolutely good — but don't chase 100% immutability, or it becomes a headache.
Abstraction
Let me be real: "over-abstraction" is not as scary as you think.
"You only use it once, no need to abstract" — this phrase is overused. The purpose of abstraction isn't "extensibility" — it's semantics. It's about seeing movementController.update(delta) and instantly knowing what it does, instead of staring at thirty lines of spaghetti mixing input handling, cursor checks, and animation switching, then spending five minutes reverse-engineering the intent.
Code you think "I'll only ever use this once" today will likely be revisited in three months when requirements change. Future you will thank past you for spending an extra ten minutes giving that logic a proper name.
Of course, I'm not advocating abstracting character movement into Strategy Pattern + Factory + Adapter just to support a swimming feature that might never exist. That is crazy. Reasonable, intentional abstraction and abstraction-for-its-own-sake are two completely different things.
Localization
Use gettext. That's it, this section is done.
If you want an explanation:
The common problem with every other approach: what you see in source code is a cold key string like "menu.start_game" instead of actual readable text.
Want to know what the button actually says? Go dig through the string table.
Want to add a new text? Create a key in the table, reference it, handle key conflicts — the development experience is painfully fragmented.
Yeah, the real issue is: you have to manually maintain a giant dictionary.
gettext is the only truly human-friendly localization solution.
How gettext works: write the original text directly in source code: _("Start Game"). Tools extract it automatically into .po files. Translators see the original text and fill in translations. Code stays readable, no ID mapping, no key conflicts. You can even write comments for translators right in the code:
// TRANSLATORS: Main menu start button, keep it short
_("Start Game")
This comment is automatically extracted into the .po file by the gettext toolchain. Translators see it instantly.
Plural forms, variable interpolation — gettext supports them natively. No manual string concatenation, no embarrassing "1 apples". This toolchain has been battle-tested for thirty years, with mature implementations in nearly every language.
Debug
Build yourself a DebugMenu, or you're just torturing yourself.
You will always need to cheat at runtime: god mode, add gold, skip levels... If every parameter tweak requires changing code, recompiling, and running to a specific scene, you're wasting your life.
ImGui has bindings in almost every language, and the API is dead simple:
ImGui::SliderFloat("Move Speed", &moveSpeed, 1.0f, 20.0f);
No XML layouts, no event system, no worrying about UI-to-game-state synchronization — because everything is redrawn every frame. Hide it behind a hotkey or a compile flag in release builds. It won't pollute your production code.
Also, you'll want shortcuts for quick testing. Use command-line arguments for profiling: --no-steam, --quickStart, --debug. For example, --quickStart can skip the start menu. Parsing these is trivial — they're just a string array.
Task Runner
> Newbie tip:
> Build scripts automate repetitive daily tasks, like: Excel to YAML conversion, packaging the game into an .exe, uploading to Steam/Itch.
Write build scripts in your own programming language. Don't learn another DSL.
Game development is full of automation tasks: packaging, deployment, localization extraction... The traditional approach is to bring in Make, or trendy stuff like Just, then spend two days figuring out their plugin ecosystems. My advice: don't.
Write these scripts in whatever language your project already uses. I use Bun + TypeScript:
async function performBuild(isRelease = false) {
const isWin = isWindows()
const task = isRelease
? "desktopApp:createReleaseDistributable"
: "desktopApp:createDistributable"
const subDir = isRelease ? "main-release" : "main"
const buildFolder = isWin ? "build-win" : "build-linux"
const gradleGenPath = `desktopApp/${buildFolder}/compose/binaries/${subDir}/app/GoodIdleGame`
const platformTag = isWin ? "windows-x64" : "linux-x64"
const finalOutputPath = `${CONFIG.paths.outputBase}/${platformTag}/${isRelease ? "release" : "debug"}`
log.step(`Running Gradle task: ${task}`)
await $`./gradlew ${task}`.throws(true)
log.info(`Preparing directory: ${finalOutputPath}`)
await rm(finalOutputPath, { recursive: true, force: true })
await mkdir(finalOutputPath, { recursive: true })
log.info(`Moving artifacts...`)
await cp(gradleGenPath, finalOutputPath, { recursive: true })
const libs = isWin
? [
`${CONFIG.paths.libs.win}/steam_api64.dll`,
`${CONFIG.paths.libs.win}/steamworks4j64.dll`,
]
: [
`${CONFIG.paths.libs.linux}/libsteam_api.so`,
`${CONFIG.paths.libs.linux}/libsteamworks4j.so`,
]
for (const lib of libs) {
const fileName = basename(lib)
const dest = join(finalOutputPath, fileName)
await cp(lib, dest).catch(() => log.error(`Missing lib: ${lib}`))
}
if (!isRelease) {
await cp(
`${CONFIG.paths.libs.base}/steam_appid.txt`,
join(finalOutputPath, "steam_appid.txt"),
)
}
return finalOutputPath
}
The main benefit is you're really using a programming language. You can easily write real logic. Imagine implementing the flow above in Just. Or imagine the pain of releasing without any task runner.
Or a simpler scenario: you want the built artifact named [game_name]_[version].zip — you can trivially pull the version from your game's codebase.
It's 2026. You might not write much code yourself anymore. But architectural decisions are still yours — because the quality of AI-generated code largely depends on how clear the context you give it. These principles work well for AI too.
Originally published on my blog: blog