A server-side backend and Unity 2D client for a football tactics game. Players choose a tactic, the server simulates the match, and the result feeds into a quest system, a coin economy, and a leaderboard. The Unity client is a work in progress — the server is fully functional, the UI is built, and I'm working on wiring them together.
I built this because I'm a football fan who wanted to understand how game backends are structured, particularly the kind of systems that run in the background of a live game — match results, player progression, rewards, rankings. It's also my first time working with Unity, so this project spans both backend development and game client work.
When a player submits a match, they send three things: their player ID, an opponent team ID, and a tactic choice (Attack, Balanced, or Defend). That's it. They do not send a score or a seed.
The server generates a random seed, runs the simulation, and returns the full result. Keeping the seed server-side means the client cannot manipulate the outcome — a modified client or a replayed HTTP request still gets a legitimately generated match result.
The tactic choice has a real effect on the match:
- Attack — 6 player shot attempts, 5 for the opponent. More chances to score, but more exposure.
- Balanced — 4 vs 4. Neutral.
- Defend — 3 vs 2. Fewer shots overall, but the opponent is suppressed more than you are.
Each shot attempt has a 40% conversion chance. Goals from both sides determine the outcome (Win, Draw, Loss).
After the match resolves, the server emits a list of typed events — MatchPlayed, GoalsScored, MatchWon, CleanSheet — and passes them to the quest engine. The quest engine processes each active quest independently against that event list, updates progress, and awards coins if a quest completes.
The server is split into two projects:
TacticsLeague.Domain contains MatchSimulator and QuestEngine. Neither class has any dependency on ASP.NET Core or on any database. They take inputs, return outputs. This means they can be unit-tested directly, and they won't change if the API layer or storage layer ever changes.
TacticsLeague.Api contains the controllers and GameState. GameState is infrastructure — it holds in-memory player data and wires the domain logic together. The three controllers are thin: they validate input, call into domain or state, and return the result.
The separation was intentional. I wanted MatchSimulator and QuestEngine to be ignorant of each other as well. The simulator doesn't know what quests exist. The quest engine doesn't know how goals are scored. The event list is the contract between them — if I add a new quest type, I add a new MatchEventType and nothing else in the simulator changes.
State lives in ConcurrentDictionary collections on a singleton GameState. This is a deliberate MVP tradeoff: there's no database, and state is lost on restart. The concurrency primitives make individual reads and writes safe, but ApplyMatch is not fully atomic across all three stores (quest progress, coins, leaderboard). For the current scope this is acceptable, and it's acknowledged in docs/api-design.md.
There are four quests in the current catalog:
| Quest | Trigger | Target | Reward |
|---|---|---|---|
| Score 5 Goals | GoalsScored event |
5 | 100 coins |
| Win 3 Matches | MatchWon event |
3 | 150 coins |
| Keep 2 Clean Sheets | CleanSheet event |
2 | 120 coins |
| Play 10 Matches | MatchPlayed event |
10 | 80 coins |
Rewards are awarded exactly once. QuestProgress carries separate IsCompleted and RewardClaimed flags — if a quest is already completed or its reward already claimed, QuestEngine returns zero coins and leaves the record unchanged.
Updated after every match. Sorted by: points descending, then wins, then goals scored, then matches played ascending (as a tiebreaker that favors players who reached the same point total in fewer games). Points: Win = 3, Draw = 1, Loss = 0.
| Method | Path | Description |
|---|---|---|
| POST | /api/matches/simulate |
Simulate a match and update player state |
| GET | /api/players/{playerId}/quests |
Get quest progress for a player |
| GET | /api/leaderboard |
Get the full leaderboard |
Full request/response shapes are in docs/api-design.md.
OpenAPI JSON is available at /openapi/v1.json when running in Development mode.
tactics-league-liveops/
├── server/
│ ├── TacticsLeague.Domain/
│ │ ├── Matches/ # MatchSimulator, MatchInput, MatchResult, Tactic, MatchEvent
│ │ └── Quests/ # QuestEngine, QuestDefinition, QuestProgress, QuestUpdateResult
│ ├── TacticsLeague.Api/
│ │ ├── Controllers/ # MatchesController, PlayersController, LeaderboardController
│ │ ├── GameState.cs # In-memory state, quest catalog, leaderboard
│ │ └── Program.cs # DI registration, middleware
│ └── TacticsLeague.Tests/
│ ├── MatchSimulatorTests.cs
│ └── QuestEngineTests.cs
└── client-unity/
└── TacticsLeagueClient/ # Unity 6 2D project (UI built, API integration in progress)
15 unit tests across MatchSimulator and QuestEngine.
MatchSimulatorTests covers: determinism (same seed → same result), correct event emission for goals, wins, and clean sheets, and tactic passthrough on the result.
QuestEngineTests covers: matching vs non-matching events, multiple events summed, progress capped at target, reward granted on first completion, no double-reward on already-completed quests, RewardClaimed flag persistence, and an ArgumentOutOfRangeException on negative event counts.
cd server
dotnet test| Layer | Technology |
|---|---|
| Server | .NET 10, ASP.NET Core |
| Testing | xUnit 2.x |
| Unity client | Unity 6000.4.10f1, URP 2D |
| UI | TextMeshPro |
Coming soon — server is running, Unity UI is built, currently connecting the two.
Designing MatchInput and MatchResult took more thought than I expected. The question of what belongs in each type is really a question of trust: what does the client get to decide, and what does the server have to own? Once I framed it that way, the separation became clearer — the client expresses intent, the server resolves the outcome.
The tactic system also needed more care than the initial version had. The first implementation gave both the player and opponent the same number of shot attempts regardless of which tactic was selected, which meant the choice had no effect. The current version gives Attack a slight offensive edge at the cost of more opponent attempts, while Defend suppresses both sides but gives the opponent fewer chances than the player.
The event-driven quest system was the piece I'm happiest with. Keeping MatchSimulator and QuestEngine ignorant of each other means either can change independently. It also made the tests easier to write — I can test quest logic by constructing any event list directly, without needing to run a match.
- Connect the Unity client to the API (
UnityWebRequestcalls fromGameDemoController) - Add more match events and quest types
- Tactic effects on the player's squad composition or player stats
- Persist state to a database so it survives server restarts
- CORS configuration for the Unity build
- Load the quest catalog from config rather than hardcoding it in
GameState
| Tool | Version |
|---|---|
| .NET SDK | 10.0+ |
| Unity Editor | 6000.4.10f1 |
cd server
dotnet restore
dotnet buildcd server/TacticsLeague.Api
dotnet runAPI runs at http://localhost:5118 and https://localhost:7158.
- Open Unity Hub, click Add, and select
client-unity/TacticsLeagueClient. - Open with Unity 6000.4.10f1.
- Open
Assets/Scenes/MainScene.Unity. - Start the server first, then press Play.
The client currently shows placeholder data while API integration is in progress.