sharedlogic

Hybrid Client-Server Architecture for Mobile Games: Shared Logic Approach

In the mobile game industry, it’s common for several backend developers to be assigned to a single project. For example, studios developing a PvP shooter with a meta-game often employ 5–8 server specialists—and this is considered standard practice.

However, in our company (around 40 employees, 3 projects in simultaneous development), a single backend developer successfully manages this task.

Our games are difficult to hack: every player action is always verified by the server, which practically eliminates cheating. We’ve been using this approach for many years, having tested it on dozens of projects, and I don’t know of a single case of someone who started working this way ever going back to more classic architectures.

This article is about how to implement a hybrid client-server interaction architecture that allows you to achieve a high level of security with minimal resources. This solution benefits everyone:

When designing a mobile game architecture, it’s important to understand that there are two types of client-server interaction, each requiring different approaches and solutions:

Let’s look at how each of these is structured.

Real-time interaction

There are many solutions for real-time interaction. Among the most popular for Unity projects are:

This is just a small sample of the available options. Approaches range from full client trust and peer-to-peer, to classic authoritative servers with client-side prediction, lag compensation, and other mechanisms.

We won’t focus on real-time interaction here, as this is a well-explored field with many ready-made solutions; usually, you just need to pick what best fits your game’s and team’s requirements.

Turn-based mode with persistent state

It’s a rather clumsy term, but this model is found in the vast majority of modern games. For example, at the beginning of the game, you’re often prompted to create your own character. Frequently, your character will gradually acquire additional customization items or weapons. This also includes various in-game currencies, loot boxes, and other mechanics commonly used in Free-to-Play games.

There are four widespread approaches here:

Let’s briefly review them.

Authoritative server + thin client

💡 Authoritative server:
All game logic and action validation are performed on the server. The client is just the interface and “control panel.” This provides maximum protection against cheating, but results in poor responsiveness and possible lag for the player.

This setup means that all player action processing logic and the player’s state are stored on the server. The client is used to display the state and send commands to the server.

It’s a great solution, but it comes with technical and often organizational challenges:

Despite these drawbacks, this is still a good approach for PC and console games, as proven by thousands of successful projects.

Minimal state server + thick client

💡 Thick client:
All game logic lives on the client; the server simply saves the data it receives. This is very simple and inexpensive, but any data can easily be tampered with—protection is minimal.

Here, almost all the logic is on the client, while the server just saves the player’s state received from the client. The server trusts whatever the client sends.

If the game doesn’t have in-app purchases or online interactions between players, this is a workable approach.

Hybrid approach

💡 Hybrid:
Fast feedback is achieved by running logic on both client and server. Critical checks are duplicated, reducing the risk of cheating but making development and debugging more complex. This is the most expensive and most common option.

Often, the delays inevitable in a thin client are unacceptable. For example, when rearranging buildings on your base, you need to check whether a building overlaps with others, exceeds the player’s area, or is in a state that allows it to be moved. In such cases, all these checks often have to be performed on the client for “responsiveness”—and then repeated on the server. The server may use a different data organization, may be written in a different programming language, and may be handled by a different developer.

Key features:

Despite being the most labor-intensive, the hybrid approach is actually the most common, sometimes leaning closer to a thin client or closer to a minimal server with a thick client. In the thick client case, the server usually still tries to add checks for the most blatant hacks. In the thin client case, most of the logic is duplicated on the client to reduce interface delays.

I would also consider solutions like PlayFab or UnityGameServices Economy to be hybrid approaches.

Shared Code Between Client and Server

💡 Shared Logic:
The same code for processing game commands runs both on the client and the server.
This provides instant feedback for the player and simultaneously protects against cheating: the server re-executes the command and compares the resulting state with the client’s.

There is an approach that combines development convenience with a high level of security—the Shared Logic architecture. Here, both the client and server use a common set of commands and processing logic, conceptually similar to the Command design pattern but adapted for use across both client and server.

Whenever the player performs an action, the corresponding command is executed on both sides and changes the player’s profile state in exactly the same way. At the end, the server compares the hash of the profile state: if it matches the client’s, everything is correct. If not, it indicates tampering with the data or logic, and such a case is considered an attempt to cheat.

Concept image

Before diving into the details, let’s see how this looks in the context of a specific game.

Applying the Concept: The Example of Connect Four

For each game, we write our own code implementing its rules and logic.

At a minimum, we need to:

As an illustration, here’s the implementation of the well-known Connect Four game. Players take turns choosing which column to drop a disc into, and the first to align 4 discs in a row wins.

Connectfour image

To keep it simple, let’s assume that we play only against the computer—after the player’s move, the computer immediately makes its move.

Thus, we only need two commands:

Sending Commands

In the simplest case, in a Unity client, calling these commands can look like this:

if (_profile.Result == GameResult.InProgress)
{
    // Show 7 drop buttons, one for each column
    for (int col = 0; col < 7; col++)
    {
        if (GUILayout.Button("↓", GUILayout.Width(30)))
        {
            // If the '↓' button is pressed, execute the command, check the hash, send it to the server, and verify it there
            ExecuteCommand(new DropDiscCommand { Column = col });
        }
    }
}
else
{
    if (GUILayout.Button("New Game"))
    {
        // If the "New Game" button is pressed, start a new game
        ExecuteCommand(new NewGameCommand());
    }
}

The call to ExecuteCommand(...) is straightforward, even though it means a whole sequence of actions, including local execution and server-side verification.

Displaying State

Suppose the user profile (or game state) is stored in a variable called _profile and defined like this:

public enum CellState { Empty, Player, Computer }

public class ConnectFourProfile : UserProfile
{
    // In Connect Four, the board is usually 6x7.
    // The profile stores the entire game board state.
    public CellState[,] Board = new CellState[6, 7];
    // ...
}

Then the board can be displayed in the client like this:

// Display game board
for (int row = 0; row < 6; row++)
{
    GUILayout.BeginHorizontal();
    for (int col = 0; col < 7; col++)
    {
        var cell = _profile.Board[row, col];
        string label = cell switch
        {
            CellState.Player => "🟡",
            CellState.Computer => "🔴",
            _ => "⚪"
        };
        GUILayout.Label(label, GUILayout.Width(30));
    }
    GUILayout.EndHorizontal();
}

Here, Unity’s OnGUI method is used for interface visualization, which is generally not used in real projects, but is great for demonstration purposes due to its simplicity.

Implementing Command Processing

A command means modifying the player’s profile. In the simplest case, it can be just a line or two, for example, if we want to reset the state as with the NewGameCommand.

In general, we just specify which type of command calls which code; in this code, we can modify the player’s profile, and it will change both on the client, the server, and in the database.

Some commands, like DropDiscCommand, are more verbose, but it’s just ordinary C# code—nothing special.

public ConnectFourCommandProcessor()
{
    // The new game command clears the board and resets the game status.
    // Victory and defeat stats remain unchanged.
    RegisterHandler<ConnectFourProfile, NewGameCommand>((userProfile, cmd) =>
    {
        userProfile.Board = new CellState[6, 7];
        userProfile.Result = GameResult.InProgress;
    });

    // This is the main command handler - dropping a disc.
    // The game is always against the computer in this example.
    RegisterHandler<ConnectFourProfile, DropDiscCommand>((userProfile, cmd) =>
    {
        if (userProfile.Result != GameResult.InProgress) return; // Ignore command if game is finished

        // Player's move
        var row = DropDisc(userProfile.Board, cmd.Column, CellState.Player);
        if (row == -1)
            throw new Exception("Column full");

        // Did the player win?
        if (CheckVictory(userProfile.Board, row, cmd.Column, CellState.Player))
        {
            userProfile.Result = GameResult.Win;
            userProfile.VictoryCount++;
            OnVictory?.Invoke();
            return;
        }

        // Computer's move (rule-based AI)
        var rand = new DeterministicRandom(userProfile.Seed++); // Deterministic random generator
        int baseCol = rand.Next(0, 7); // Starting point for fallback column choice
        int aiCol = ChooseBestMove(userProfile.Board, baseCol);
        int aiRow = DropDisc(userProfile.Board, aiCol, CellState.Computer);

        // Did the computer win?
        if (CheckVictory(userProfile.Board, aiRow, aiCol, CellState.Computer))
        {
            userProfile.Result = GameResult.Defeat;
            userProfile.DefeatCount++;
            OnDefeat?.Invoke();
        }

        // Check for draw
        if (userProfile.Result == GameResult.InProgress && IsBoardFull(userProfile.Board))
        {
            userProfile.Result = GameResult.Draw;
            OnDraw?.Invoke();
        }
    });
}

A notable point here is the use of the DeterministicRandom helper class, because we need the random number generator to produce the same result on the same user profile with the same command. This cannot be guaranteed with System.Random, even with a synchronized seed, since it may produce different results on different platforms.

In fact, the code above would look almost the same even if we didn’t use the Shared Logic approach. There’s nothing revolutionary here; it doesn’t require you to think about multithreading, reactive programming, ECS, asynchronous execution, etc. Even a developer with little experience can write this code—one, instead of two developers (one for the frontend and one for the backend).

Main Modules and Their Roles

Let’s now move from examining a practical example to discussing the implementation details of the Shared Logic system as a whole.

Some readers may find it more convenient to explore the source code directly before diving into the detailed explanation below.
🔗 View the full source code on GitHub

Our system consists of several separate modules, each a standalone project within a .NET solution and performing a well-defined role:

Shared Logic Core is connected to Shared Logic Server as a project dependency; in the Unity client, it’s connected via Unity’s Package Manager.
Specific Game Plugin is loaded by the backend service at startup, allowing you to use the same backend for different games without rebuilding. In the Unity client, Specific Game Plugin is also connected via Unity’s Package Manager.
The example Specific Game Plugin contains code for the well-known game Connect Four.

Implementation Details

Shared Logic Core

The system’s core is here. The idea is that you don’t need to change it for each game—it’s simply reusable code from project to project.
For demonstration, all the code for this module is in a single file, SharedLogic.cs. Since there isn’t much code, it’s easier to navigate.

Class diagram

Main classes:

Shared Logic Server

This turns out to be one of the simplest parts of the system. It’s convenient to start with Program.cs: it loads SpecificGamePlugin.dll and instantiates the main game logic class, which must inherit from GameCommandProcessor.

Besides that, the usual WebAPI service setup is performed: serializer configuration and MongoDB driver initialization.

Newtonsoft.JSON is used for serialization—not the fastest, but illustrative and compatible with AOT (important for Unity builds).

MongoDB is used as the database. It’s a convenient choice that requires virtually no setup after installation, and with MongoDB Compass you can conveniently inspect records in a readable form.

Key class:
CommandController

[ApiController]
[Route("api/[controller]")]
public class CommandController : ControllerBase
{
    public async Task<IActionResult> LoadProfile(Guid userId);
    public async Task<IActionResult> Execute([FromHeader(Name = "X-User-Id")] Guid userId, [FromBody] ExecuteCommandRequestDTO request);
}

When called from the client, LoadProfile simply retrieves the user’s profile from the database using UserProfileRepository and returns it (needed once at game startup).

When executing a command (Execute), the controller retrieves the user profile, looks up and deserializes the command using the command type name, and executes it via GameCommandProcessor. The CommandController then compares the updated profile hash to the client’s value for verification. If everything matches, it saves the updated profile and returns success; otherwise, it returns an error.

Specific Game Plugin

For each game, we write our own code implementing its rules and logic.

For simplicity, it’s all in one file here—SpecificGamePlugin.cs—but in real projects, this is usually a set of files and classes. This code is connected to the Unity client (as source code via manifest.json / package.json) and to the server as SpecificGamePlugin.dll placed in SharedLogicServer/Plugins/SpecificGamePlugin.dll.

ConnectFourProfile:

public class ConnectFourProfile : UserProfile
{
    public CellState[,] Board = new CellState[6, 7];
    public GameResult Result = GameResult.InProgress;
    public int Seed; 
    public int VictoryCount = 0;
    public int DefeatCount = 0;
}

Adds fields specific to the Connect Four game to the base user profile. Board has a size of 6 rows and 7 columns.
Seed is necessary to ensure that when commands are executed, you get the same result on both the client and server, so the random number generator must use the same seed.

DropDiscCommand:

public class DropDiscCommand : ICommandData
{
    public int Column { get; set; }
}

The main command in the game: each turn, the player chooses which column to drop their disc into.

NewGameCommand:

public class NewGameCommand : ICommandData { } 

The command to start a new game. Sometimes commands don’t even contain any parameters.

ConnectFourCommandProcessor:

public class ConnectFourCommandProcessor : GameCommandProcessor
{
   public ConnectFourCommandProcessor()
   {
       // The new game command clears the board and resets the game status.
       // Victory and defeat stats remain unchanged.
       RegisterHandler<ConnectFourProfile, NewGameCommand>((userProfile, cmd) =>
       {
          // new game command implementation
       });

       RegisterHandler<ConnectFourProfile, DropDiscCommand>((userProfile, cmd) =>
       {
          // drop disc command implementation
       });
   }

   public override UserProfile CreateDefaultProfile(Guid userId);
   // additional helper methods
}

This class ties everything together, registering the handlers for DropDiscCommand and NewGameCommand. It also describes how to create a new user profile (CreateDefaultProfile). When handling commands, it uses helper methods related to the specific game’s logic, located in the same class.

DeterministicRandom:
A simple deterministic random number generator. As noted above, it ensures that the same sequence of random numbers is produced on both client and server for the same user profile and command.

Sequence Diagram for Command Execution

Below is an example of the sequence for the DropDisc command:

Class diagramm

Pitfalls, Limitations, and Debugging Features

Of course, there are limitations to this approach.
You need the states to match bit-for-bit—otherwise, the hashes won’t match.

What can cause problems?

💡 Debugging without a server:
You can implement a debug mode just for the game in the editor, which does not require sending data to the server and instead saves UserProfile to disk and loads it from there at startup.

Using tools like MongoDB Compass allows you to view user profiles in a very convenient way and even edit them.

Performance Testing

All this is great, but how fast is it?

The main load usually comes from serialization:

We can eliminate steps 2 and 6 by overriding hash calculation methods.
We can almost eliminate steps 4 and 5 by caching the UserProfile in memory, so we don’t need to access the database each time; in memory, it can be stored in deserialized form.

💡 Database Caching:
Caching requests not only reduces the database load but also greatly reduces the number of heavy serialization-deserialization operations.

Additionally, instead of JSON, you can use binary serialization formats such as MessagePack. This can speed up serialization by 5–10 times, but it comes at the cost of convenience: debugging and inspecting data becomes less straightforward.

But let’s just see how it works as simply as possible.
For this, I wrote a benchmark that creates a user, executes 10 “drop disc” commands in different columns, then executes a “start new game” command and does another 10 moves. You can see the source code in Benchmark/Program.cs.

Here are the results on an AMD Ryzen 9 5900X (12 cores):

E:\Projects\SharedLogic.github\sharedlogic\Benchmark\bin\Release\net8.0>Benchmark.exe
Benchmark completed.
Total requests: 70400
Elapsed time: 3.97 seconds
Requests per second: 17712.19

For comparison, here are the results on a 7-year-old notebook with Core-i5-8300H (4 cores):

C:\Projects\SharedLogic\Benchmark\bin\Release\net8.0>Benchmark.exe
Benchmark completed.
Total requests: 70400
Elapsed time: 21.19 seconds
Requests per second: 3322.58

You can see that performance scales perfectly with increased processing power.

Is 17,000 requests per second a lot or a little?
If we use this system as intended, the user’s request frequency is usually not that high. These are inventory operations, equipment changes, lootbox openings and similar actions — in reality, on average about 1 request per 10 seconds per user.
So, even the simplest implementation with a small profile and on an ordinary desktop computer could handle 80,000–170,000 concurrent users, which is definitely enough for most projects.

Just for fun, I tried adding simple in-memory profile caching to reduce database requests. Here are the results on an AMD Ryzen 9:

E:\Projects\SharedLogic.github\sharedlogic\Benchmark\bin\Release\net8.0>Benchmark.exe
Benchmark completed.
Total requests: 70400
Elapsed time: 0.96 seconds
Requests per second: 73262.07

This already means hundreds of thousands of CCU — a great result in my opinion!

💡 Reverse Proxy:
For production .NET servers, it’s best to run them behind a reverse proxy — for example, Nginx.
This improves security, enables HTTPS, improves performance, limits the number of connections, and makes the app more robust.
.NET 8 (Kestrel) is not intended for direct exposure to the Internet.

Conclusions

Our team’s experience shows: implementing a hybrid Shared Logic architecture for mobile games offers real advantages — both in security and development speed.
A single backend developer can maintain several game projects at once without a loss in quality, something that seemed impossible before.

💡 Production-Proven:
In our company, more than a dozen projects have been successfully released using this architecture — and it has proven its effectiveness in practice.

This approach frees the team from routine, saves the business resources, and gives players instant feedback and cheat protection.
The server confirms every player action by checking the state, and any attempt to bypass the system is easily detected.

Key takeaways:

If you’d like to adopt this approach, I’ll be happy to share my experience, discuss details, or consult with your team.
Feel free to reach out — I’m always open to collaboration!

🔗 Full source code and implementation on GitHub

Contact:
LinkedIn: nlezhnev
Telegram: @nikolaylezhnev