The evolution of the game framework. Client 3. Layers of logic
Before we looked at the separation of display logic from graphics, as well as various helper classes and managers. All together they form the framework of our applications and were moved to a separate library – Core Framework. It remains to develop a methodology for writing the rest of the logic. It includes business logic and game rules, data and its processing, as well as interaction with the server.
All logic will be divided into layers. The main meaning of the layers is that the classes of one layer are as independent as possible from the classes from neighboring layers and are absolutely independent from the rest. All this no longer applies to the main framework (Core Framework), but to frameworks for different groups of genres (Base Game Frameworks) and for each individual genre (Game Frameworks).
This results in the following hierarchy of libraries:
Business Logic Department
So far, we have had all the logic in the components (Component), which means that we have not yet made a separation between the display logic (view logic) and the logic of the subject area (domain logic), or business logic (business logic). That is, both the display logic and the business logic are implemented in the same class. This is nothing if one person is involved in the project, but if there are several of them, then it becomes difficult to share powers in the team. Yes, and the class itself can become large and confusing.
However, we do not separate different types of logic out of simple love for order. It is very difficult to use different display logics with the same business logic (and vice versa) if they are merged into one class. Also, you cannot develop them separately from each other: if we inherit from a class to complicate the display logic, we automatically drag the display logic with us, because they are all implemented in one class. Also, they cannot be used separately. For example, if the business logic is implemented on the server, and we only need display logic in the client.
To better understand how one logic differs from another, consider the following example. Let’s say we have components for displaying different pieces on a chessboard. On this board you can play chess, checkers, giveaway, corners and many other games. The visualization of the state of the game and the movement of elements on the board is the display logic, and the rules of the game, according to which the necessary commands are sent at the right time, are the business logic.
Here, for all the listed games, only one module of one or more components will be enough to display figures or checkers on the board. And for each new game, one class with rules (controller) will then be created. And if we are making a thin client that uses a generalized, universal protocol, then there is no need to do this either – the creation of business logic is shifted to the server team.
Model
The program is a reflection of reality. Unfortunately, we are not yet able to repeat reality in its entirety. Yes, this is not necessary. To solve specific problems, it is enough for us to reflect only its main, essential aspects and relations. That is to build a model of reality. In our case, the model is represented by two aspects: state and actions. State is data stored in variables, that is, in memory, and actions are instructions that are executed by the processor. Processor instructions are too primitive, so we will take functions as the minimum units of action.
Those classes that contain data and functions for their direct processing and nothing else, we will call models. In our game, the model class consists of a configuration of the selected clothing numbers of each type (an array of integers state
) and functions that change individual clothes (changeItem()
) or all of them together (set_state()
):
class DresserModel
{
// State
public var state(default, set):Array<Int> = [];
public function set_state(value:Array<Int>):Array<Int>
{
if (value == null)
{
value = [];
}
if (!ArrayUtil.equal(state, value))
{
state = value;
stateChangeSignal.dispatch(value);
}
return value;
}
public var stateChangeSignal(default, null) = new Signal<Array<Int>>();
public var itemChangeSignal(default, null) = new Signal2<Int, Int>();
public function changeItem(index:Int, value:Int):Void
{
if (state[index] != value)
{
state[index] = value;
itemChangeSignal.dispatch(index, value);
}
}
}
The main (and only) game component Dresser with the model will look a little more massive than before:
class Dresser extends Component
{
// Settings
public var itemPathPrefix = "item";
// State
private var model:DresserModel;
private var items:Array<MovieClip>;
// Init
override private function init():Void
{
super.init();
model = ioc.create(DresserModel);
model.stateChangeSignal.add(model_stateChangeSignalHandler);
model.itemChangeSignal.add(model_itemChangeSignalHandler);
}
override public function dispose():Void
{
if (model != null)
{
model.stateChangeSignal.remove(model_stateChangeSignalHandler);
model.itemChangeSignal.remove(model_itemChangeSignalHandler);
model = null;
}
super.dispose();
}
// Methods
override private function assignSkin():Void
{
super.assignSkin();
var itemPaths = cast resolveSkinPathPrefix(itemPathPrefix);
items = [for (path in itemPaths) resolveSkinPath(path)];
for (item in items)
{
item.stop();
item.buttonMode = true;
item.addEventListener(MouseEvent.CLICK, item_clickHandler);
}
// Apply
refreshState();
}
override private function unassignSkin():Void
{
items = null;
super.unassignSkin();
}
private function switchItem(index:Int, step:Int=1):Void
{
var item = items[index];
if (item != null)
{
var value = (item.currentFrame + step) % item.totalFrames;
value = value < 1 ? item.totalFrames - value : value;
model.changeItem(index, value);
}
}
private function refreshState():Void
{
for (i => v in model.state)
{
var item = items[i];
if (item != null)
{
item.gotoAndStop(v);
}
}
}
// Handlers
private function item_clickHandler(event:MouseEvent):Void
{
var item:MovieClip = Std.downcast(event.currentTarget, MovieClip);
var index = items.indexOf(item);
switchItem(index, 1);
}
private function model_stateChangeSignalHandler(value:Array<Int>):Void
{
refreshState();
}
private function model_itemChangeSignalHandler(index:Int, value:Int):Void
{
var item = items[index];
if (item != null)
{
item.gotoAndStop(value);
}
}
}
Service department
So far, it is not clear why it is so complicated. But it is worth adding the need to support the server to the list of requirements, and the justification for this decision becomes obvious. We can implement the second model with support for network mode, and at the same time we will also have its first version – for standalone mode. We can substitute any component as we wish, since they have one interface, and for Dresser they are indistinguishable.
But the servers may be different. It may be an HTTP server, it may be on sockets. In addition, all of them may have different versions with a different protocol. In order not to create a separate model for each, we will take out the code responsible for connecting and sending data to a special class – a service, or a service. It is called a service because, from the point of view of the application, it performs a certain service function. In this case, this is a centralized storage of data on a remote machine.
class DresserModel
{
// State
private var service:DresserService;
private var _state:Array<Int> = [];
@:isVar
public var state(get, set):Array<Int>;
public function get_state():Array<Int>
{
return _state;
}
public function set_state(value:Array<Int>):Array<Int>
{
if (value == null)
{
value = [];
}
if (!ArrayUtil.equal(_state, value))
{
service.setState(value);
}
return value;
}
// Signals
public var stateChangeSignal(default, null) = new Signal<Array<Int>>();
public var itemChangeSignal(default, null) = new Signal2<Int, Int>();
// Init
public function new()
{
service = new DresserService();
service.loadSignal.add(service_loadSignalHandler);
service.stateChangeSignal.add(service_stateChangeSignalHandler);
service.itemChangeSignal.add(service_itemChangeSignalHandler);
}
// Methods
public function load():Void
{
service.load();
}
public function changeItem(index:Int, value:Int):Void
{
if (state[index] != value)
{
service.changeItem(index, value);
}
}
// Handlers
private function service_loadSignalHandler(value:Dynamic):Void
{
_state = cast value;
stateChangeSignal.dispatch(value);
}
private function service_stateChangeSignalHandler(value:Array<Int>):Void
{
_state = cast value;
stateChangeSignal.dispatch(value);
}
private function service_itemChangeSignalHandler(index:Int, value:Int):Void
{
state[index] = value;
itemChangeSignal.dispatch(index, value);
}
}
class DresserService
{
// Settings
public var url = "http://127.0.0.1:5000/storage/dresser";
// State
public var loadSignal(default, null) = new Signal<Array<Int>>();
public var stateChangeSignal(default, null) = new Signal<Array<Int>>();
public var itemChangeSignal(default, null) = new Signal2<Int, Int>();
public function load():Void
{
new Request().send(url, null, function (response:Dynamic):Void {
var data = parseResponse(response);
if (data.success)
{
loadSignal.dispatch(data);
}
});
}
public function setState(value:Array<Int>):Array<Int>
{
new Request().send(url, value, function (response:Dynamic):Void {
var data = parseResponse(response);
if (data.success)
{
stateChangeSignal.dispatch(data);
}
}, URLRequestMethod.POST);
}
public function changeItem(index:Int, value:Int):Void
{
if (state[index] != value)
{
new Request().send(url, {index: index, value: value}, function (response:Dynamic):Void {
var data = parseResponse(response);
if (data.success)
{
itemChangeSignal.dispatch(index, value);
}
}, "PATCH");
}
}
private function parseResponse(response:Dynamic):Dynamic
{
Log.debug('Load data: ${response} from url: $url');
try
{
var data:Dynamic = Json.parse(response);
Log.debug(' Loaded state data: ${data} from url: $url');
return data;
}
catch (e:Exception)
{
Log.error('Parsing error: $e');
}
return null;
}
}
Together, all services form a layer of services, and models, surprisingly, a layer of models. Just as there can be different implementations of models for the same view, so there can be multiple versions of services that fit the same model. Each layer has its own space for development, limited by a set of public properties and methods. And as long as the changes do not go beyond the class interface, they do not entail changes in the classes of other layers, they are a purely internal matter.
Transport and parser department
Within each service, two more entities can be distinguished, which may vary. This is a way to encode (parser) and a way to send (transport) messages. (Indeed, we should be able to change the message format from JSON to XML, or the forwarding protocol from HTTP to TCP sockets, without having to duplicate all the rest of the service code.) In their simplest form, their interfaces might look like this:
interface IParser
{
// Methods
public function serialize(commands:Dynamic):Dynamic;
public function parse(plain:Dynamic):Array<Dynamic>;
}
interface ITransport
{
// Settings
public var url:String;
// Signals
public var receiveDataSignal(default, null):Signal<Dynamic>;
public var errorSignal(default, null):Signal<Dynamic>;
// Methods
public function send(plainData:String, ?data:Dynamic):Void;
}
In the case of using sockets, you will need to extend the interface by adding methods and properties to establish a connection to it (for an HTTP implementation, unnecessary methods and properties can be left empty):
interface ITransport
{
// Settings
public var reconnectIntervalMs:Int;
public var isOutputBinary:Bool;
public var isInputBinary:Bool;
// State
public var host(default, null):String;
public var port(default, null):Int;
public var isConnecting(default, null):Bool;
public var isConnected(get, null):Bool;
// Signals
public var connectingSignal(default, null):Signal<ITransport>;
public var connectedSignal(default, null):Signal<ITransport>;
public var disconnectedSignal(default, null):Signal<ITransport>;
public var closedSignal(default, null):Signal<ITransport>;
public var reconnectSignal(default, null):Signal<ITransport>;
public var receiveDataSignal(default, null):SignalDyn;
public var errorSignal(default, null):SignalDyn;
// Methods
public function connect(?host:String, ?port:Int):Void;
public function close():Void;
public function send(plainData:Dynamic):Void;
}
But for now let’s do sending JSON objects over HTTP protocol:
class JSONParser implements IParser
{
// Methods
public function serialize(commands:Dynamic):Dynamic
{
return Json.stringify(commands);
}
public function parse(plain:Dynamic):Array<Dynamic>
{
if (plain == null)
{
return null;
}
var data:Dynamic = Json.parse(plain);
return Std.isOfType(data, Array) ? data : [data];
}
}
class HTTPTransport implements ITransport
{
// Settings
public var url:String;
// Signals
public var receiveDataSignal(default, null) = new Signal<Dynamic>();
public var errorSignal(default, null) = new Signal<Dynamic>();
// Methods
public function send(plainData:String, ?data:Dynamic):Void
{
var method = data != null ? data._method : null;
var params = {data: plainData};
new Request().send(method, url, params, function(data:Dynamic):Void {
// Dispatch
receiveDataSignal.dispatch(data);
}, function(error:Dynamic):Void {
// Dispatch
errorSignal.dispatch(error);
});
}
}
The service itself will turn, firstly, into a class that coordinates the work of the transport and the parser, and secondly, into a kind of abstract interface to the remote service that stores information about command names, parameters and their types:
class DresserService extends StorageService
{
public function new()
{
super();
url = "http://127.0.0.1:5000/storage/dresser";
}
}
class StorageService implements IStorageService
{
private var parser:IParser;
private var transport:ITransport;
//...
public function new()
{
var ioc = IoC.getInstance();
parser = ioc.create(IParser);
transport = ioc.create(ITransport);
transport.url = url;
// Listeners
transport.receiveDataSignal.add(transport_receiveDataSignalHandler);
}
//...
public function setState(value:Dynamic):Void
{
var plainData = parser.serialize({state: value, _method: "POST"});
transport.send(plainData);
}
public function changeItem(index:Dynamic, value:Dynamic):Void
{
var plainData = parser.serialize({index: index, value: value, _method: "PATCH"});
transport.send(plainData);
}
//...
private function processData(data:Dynamic):Void
{
switch data._method
{
case "GET" | null:
// Dispatch
loadSignal.dispatch(data.state);
case "POST":
// Dispatch
stateChangeSignal.dispatch(data.state);
case "PATCH":
// Dispatch
itemChangeSignal.dispatch(data.index, data.value);
}
}
private function transport_receiveDataSignalHandler(plain:Dynamic):Void
{
var data:Dynamic = parser.parse(plain);
if (data != null && data.success)
{
processData(data);
}
}
}
interface IStorageService
{
// Signals
public var loadSignal(default, null):Signal<Dynamic>;
public var stateChangeSignal(default, null):Signal<Dynamic>;
public var itemChangeSignal(default, null):Signal2<Dynamic, Dynamic>;
// Methods
public function load():Void;
public function setState(value:Dynamic):Void;
public function changeItem(index:Dynamic, value:Dynamic):Void;
}
Department of Protocol
As a result, the service becomes the place where information about the internal content of command messages, that is, about the application protocol, is concentrated. And since all other functions are placed in separate classes (parser and transport), the description of the protocol now becomes the main task of the service. Therefore, it can be renamed to Protocol:
class Protocol
{
// Settings
public var url = "";
// State
private var parser:IParser;
private var transport:ITransport;
public function new()
{
var ioc = IoC.getInstance();
parser = ioc.create(IParser);
transport = ioc.create(ITransport);
transport.url = url;
// Listeners
transport.receiveDataSignal.add(transport_receiveDataSignalHandler);
}
public function send(data:Dynamic):Void
{
var plain:Dynamic = parser.serialize(data);
Log.debug('<< Send: $data -> $plain');
transport.send(plain, data);
}
// Override
private function processData(data:Dynamic):Void
{
}
private function transport_receiveDataSignalHandler(plain:Dynamic):Void
{
var data:Dynamic = parser.parse(plain);
Log.debug(' >> Recieve: $plain -> $data');
if (data != null && data.success)
{
processData(data);
}
}
}
class StorageProtocol extends Protocol implements IStorageService
{
// Signals
public var loadSignal(default, null) = new Signal<Dynamic>();
public var stateChangeSignal(default, null) = new Signal<Dynamic>();
public var itemChangeSignal(default, null) = new Signal2<Dynamic, Dynamic>();
// Requests
public function load():Void
{
send(null);
}
public function setState(value:Dynamic):Void
{
send({state: value, _method: Method.POST});
}
public function changeItem(index:Dynamic, value:Dynamic):Void
{
send({index: index, value: value, _method: Method.PATCH});
}
// Responses
override private function processData(data:Dynamic):Void
{
super.processData(data);
switch data._method
{
case "GET" | null:
// Dispatch
loadSignal.dispatch(data.state);
case "POST":
// Dispatch
stateChangeSignal.dispatch(data.state);
case "PATCH":
// Dispatch
itemChangeSignal.dispatch(data.index, data.value);
}
}
}
When going to sockets instead of field "_method"
you can use explicit command names or codes (for example, {"command": "add"}
). You can also send a whole array instead of one command at once. In many cases, this will be more convenient.
The idea of an abstract interface to a service can be developed. In fact, there are two interfaces implemented here. One is for the models, and the other is the API of the server itself. The client interface includes method signatures and commands returned by signals. The server interface consists of the format sent and received by the service (class Protocol
) commands. If for some reason they do not match (for example, if the server is developed independently of the client), then interface transformations can be performed in the service itself (Protocol
) or in the parser (create a custom subclass). This class, therefore, will become an adapter, matching different interfaces.
Controller branch
The resulting class for the protocol in the thin client can be used directly by display components without involving models. Models are needed to store and process data. And why do we need data if there is no logic that would use them. In the thin client, all data is only displayed, and for this it is not necessary to store them. The data is transferred directly to the display with each change and, if necessary, stored there, in its internal variables.
In this case, it turns out that the display calls the methods of the protocol, and then updates the graphics when it receives a signal from it. In other words, the protocol class becomes indistinguishable from the controller. And in the case of a standalone game, it is the controller, since instead of accessing the server it will contain the logic itself. Then why not call Protocol a controller? When we implement the protocol in a general form in a class, it is still Protocol, and when we inherit from it in order to configure and use it in practice, then we will already call it Controller:
class DresserController extends StorageProtocol
{
public function new()
{
super();
transport.url = "http://127.0.0.1:5000/storage/dresser";
}
}
MVC
So – for a thin client. But in a more general scheme, where some logic can be added on the client side (“thick” client, standalone application), data must be stored for this logic. That is, we need models. The components still call the controller methods for each user action, the controller makes a request to the server, and saves the received response in the model. The component listens to model signals and makes appropriate changes to the graphics. (Some signals may be directly in the controllers.)

This is how we arrived at the classic MVC pattern. This scheme works for a thin client, and for a thick one, and for a local (standalone) version – it is suitable for all occasions. In the latter case, instead of sending a request to the server, the controller itself implements the rules of the game and manipulates the models.
A certain group of closely related components, controllers, and models can form small modules that can be reused in different applications as a whole. So, it is possible to implement a system of achievements (achievements), a payment system (with shops and promotions), ratings, bonuses, and any other meta-gameplay in general as a separate module. The games themselves (gameplay) are also implemented as a separate MVC module. In fact, Base Game Frameworks and Game Frameworks only consist of such modules, while single classes are all placed in the Core Framework.
Teams
Separately, let’s say a few words about the commands, which, although they were woven into our story as if by themselves, but at the same time they themselves are not a completely trivial and obvious thing. Commands are the messages that the client exchanges with the server. In OOP terms, the method call itself is a message that one object passes to another. We would still use simple method calls if we did not need to send messages to other machines, if we did not need to implement network mode. We cannot call a method directly on a remote machine, so we are forced to use objects instead with a command field instead of the method name and other fields that replace arguments. (These objects are usually JSON-encoded for transfer over the network, although you can use any other format, including your own.)
Initially, commands were created in the protocol class when one of its methods was called. That is, a message in the form of a method call was converted into a message in the form of an object. In the opposite direction, the object was converted into one or another signal of the controller or model. But over time, it quickly became clear that it makes no sense to create a signal for each team. It’s easier to process the commands themselves in the components as they are. The commands pass from the parser to the display unchanged, where they are processed. The same mechanism can be used in normal local games, without network mode.
It turns out that it is much more convenient to create a display immediately under the commands. By doing this, we not only make it automatically suitable for online play, not only are we not tied to a specific type of controller, but we also make it possible to record and play game replays. Replays can be saved to disk and sent along with bug reports to developers for debugging. And if players don’t really need repetitions, although it’s cool, but as a debugging tool, they can sometimes be indispensable. In real development, full replays of the game are rare, but this is not because they are not needed, but because they are very expensive to implement if they are not included in the project initially. And here they get to us for free, in the load to the network mode.
In general, teams are cool. And then we move on to how their processing is implemented on the server side.
< Back | Home | Next >