We ship two games that have almost nothing in common on screen: Kart Üçlüsü, a classic casual card game, and Thornguard: Tower Defense, a strategy game. Underneath, four systems are literally the same code in both: save/load, the settings menu, the audio manager, and localization.
Two of those four we would share again without thinking. One of them cost us more than writing it twice would have. And one produced a cost that never shows up in a code review: it started pushing the two games toward looking like the same product. This post is about how to tell those cases apart before you commit to the shared version.
What we actually share
The shared surface is deliberately small:
- Save/load — getting player data onto disk and back without corrupting it.
- Settings menu — volume sliders, language picker, toggles, reset progress.
- Audio manager — source pooling, bus volumes, one-shot playback.
- Localization — key lookup, runtime language switching, font fallback.
Everything above that line — cards, dealing, boards, towers, waves, economy, tutorials, difficulty pacing — is written per game and never shared. That boundary is the whole argument of this post, and we did not get it right on the first attempt.
The two that paid off: audio and settings
The audio manager is the clearest win, because the problem it solves is identical in both games and has nothing to do with genre. Play a clip by key. Route it through a bus. Respect the mute state. Don't spawn a new AudioSource per shot. And the rule that saved us in both games: if the same clip is requested more than once in a single frame, play it once at a slightly higher volume instead of stacking identical waveforms.
That rule was written for a card game moment where several cards resolve in the same frame. It applied unchanged the first time a group of towers fired on the same tick. We didn't have to think about it twice — that's what a good shared system feels like.
The settings menu is a subtler win, and the value is not in the UI. It's in the plumbing: persisting values, applying them at boot before the first frame of audio plays, and handling the ordering problem where three systems all want to read the volume setting during initialization. Getting that startup order right is a few hours of annoying work. Doing it once and never again is worth more than the menu layout itself.
Localization: share the loader, split the tables
The loader, the formatting helpers, the language switch event and the font fallback are all generic. Those are shared and cause no trouble.
The string table is not generic, and we learned that by sharing too much of it. Shared ui.ok and ui.cancel are fine. A shared ui.round was not: a "round" in a card game and a "round" in a tower defense game are different concepts with different translations in several languages. Now every key is namespaced per game except a small common set, and the common set has to be argued for.
The one that cost us: the save system
Both games "save." That word hid the fact that they save completely different kinds of things.
The card game keeps a single persistent profile that only grows: stats, unlocks, preferences. The tower defense game keeps a run — resumable mid-wave, and thrown away the moment the run ends. One is a long-lived document, the other is a short-lived snapshot. We built one SaveManager that tried to be both, and within a few weeks it looked like this:
// The smell: shared code that knows which game it is running in
public void Save(GameState state)
{
if (GameId.Current == GameId.CardGame)
WriteSlot(0, state); // one profile, always slot 0
else
WriteSlot(state.RunIndex, state); // one slot per run
}
Every branch like this is a shared class asking to be split. The real bill arrived with schema versioning. The save format carried a single version number, so when one game's data changed shape, the other game's loader had to gain a migration step for a change that did not affect it at all. We were writing migrations for fields that didn't exist in that game.
The fix was to demote the shared part until it stopped knowing anything about games. It now handles bytes, not meaning:
public interface ISaveStore
{
void Write(string key, string json);
string Read(string key);
bool Exists(string key);
}
// Shared: paths, temp-file write, missing-file handling.
public sealed class FileSaveStore : ISaveStore
{
readonly string _root;
public FileSaveStore(string root)
{
_root = root;
Directory.CreateDirectory(_root);
}
public void Write(string key, string json)
{
var path = Path.Combine(_root, key + ".json");
var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(path)) File.Delete(path);
File.Move(tmp, path);
}
public string Read(string key)
{
var path = Path.Combine(_root, key + ".json");
return File.Exists(path) ? File.ReadAllText(path) : null;
}
public bool Exists(string key)
=> File.Exists(Path.Combine(_root, key + ".json"));
}
What a save means lives in each game, and each game owns its own version number:
// Per game: lifetime, keys, schema, migrations.
public sealed class RunRepository
{
readonly ISaveStore _store;
public RunRepository(ISaveStore store) { _store = store; }
public void Save(RunState run)
=> _store.Write($"run_{run.Slot}", JsonUtility.ToJson(run));
public RunState Load(int slot)
{
var json = _store.Read($"run_{slot}");
return string.IsNullOrEmpty(json)
? new RunState()
: JsonUtility.FromJson<RunState>(json);
}
public void Clear(int slot) => _store.Write($"run_{slot}", string.Empty);
}
The shared file went from a class both games negotiated over to an interface neither game thinks about.
The cost that isn't in the code: the games start to rhyme
A shared settings menu means the same layout, the same spacing, the same button sound, the same transition. Functionally that is fine. Visually it means one of the two games is wearing the other's clothes. A card game's UI has to survive being read at a glance while the player is holding information in their head; a tower defense HUD has different priorities. We wrote about that tension for cards specifically in Card UI Readability vs Aesthetics, and shared menu code quietly works against it, because the cheapest path is always "leave it as it is."
The more dangerous version is design, not pixels. Once two games share infrastructure, it becomes very easy to also copy a decision — a tutorial gate, a pacing shape, a reward cadence — because the code already supports it. Those are exactly the decisions that should be made per game from that game's own evidence, which is the whole point of reading a difficulty curve without telemetry. Shared tools should make it cheap to build a decision, never cheap to inherit one.
Four signs a shared system is turning into debt
- The shared code knows which game it is. Any
if (game == ...)branch is a split waiting to happen. - A change in game A requires a regression pass on game B. If a card layout tweak makes you re-test wave saving, the coupling is real.
- One game bumps a version and the other has to react. Shared schemas force shared release timing.
- A parameter exists for exactly one caller. That flag is a second implementation hiding inside the first.
The rule we use now
Share the mechanism, never the policy. How bytes reach disk, how a clip gets pooled, how a string is looked up — mechanism, share it. What a save means, when a sound plays, what the text says, how a menu looks — policy, keep it local.
The practical test: would this code exist in exactly this form if the other game had never been made? If yes, share it. If the only justification is "both games need something like this," write it twice. Duplicating eighty lines is cheaper than unwinding a wrong abstraction six months later, and the second implementation is usually shorter than the first because you already know what matters.
Comments
Be the first to comment.

