-
Notifications
You must be signed in to change notification settings - Fork 2
SNA Module Patterns
see Overview-of-SNA for background
In general, SNA-style apps are a subset of URSYS; not all apps that make use of URSYS are SNA.
An SNA app has the following criteria to help create organized yet flexible codebase:
- realtime, with steps measured in frame times (16ms to 67ms per frame) using a game loop.
- modularity along game- and app-related fault lines, using industry-standard terms and nomenclature as much as possible, to maintain separation of concerns between data, state, operations, and view.
- control in terms of dataflow (using one of three URSYS data conventions), and in execution order (controlled by URSYS lifecycle conventions) to implement a typical gameloop
- pluggable in terms of freely adding/removing feature that configure themselves based on the existing data and lifecycle conventions.
- hierarchical in structuring module dependencies, with distinct primitive layers supporting higher level layers to avoid cross talk.
- predictable by using URSYS modules and style conventions to infer what's happening when. SNA defines naming patterns to make it easy to understand a conforming codebase's organization.
criteria: control, realtime, pluggable
URSYS provides the PhaseMachine class to implement game loops that have named "phases" that correspond to typical operations. For example, a simple game loop looks like this:
graph LR
A[UPDATE GAME STATE] --> B[UPDATE PHYSICS]
B --> C[GET INPUTS]
C --> D[THINK]
D --> E[UPDATE PIECES]
E --> F[RENDER GAME]
F --> G[RENDER UI]
G --> A
You can define arbitrary phases that can be "hooked" by your components as the game loop is executed, and you can be assured that each phase completes its computation before the next one is run. This allows you to assume that game state is updated in a predictable fashion. See the PhaseMachine description below for more detail.
criteria: control, modularity
URSYS supports four kinds of programmatic invocation:
- Javascript/Typescript ES modules support
import
andexport
declarations - URSYS Endpoint class for UR_Message-based asynchronous local and network calls and events
- URSYS EventMachine class for pub/sub notification events
- URSYS PhaseMachine class for synchronize execution of groups of related functions
Note
A special case is the command-line invocation that is provided by URSYS AddOns. An AddOn can both probide the programmatic invocation methods as a module so it can be used like that, but AddOns can also include a CLI interface to provide related utilities.
You're likely already familiar with importing modules using the import
syntax, which couples code modules together in a direct dependency. You then export
your API for use by other ES modules.
// non-working example demonstrating syntax
import { RenderPass } from 'class-renderpass.ts';
// declarations
function RenderPass(rp:string) {}
function Render() {}
// export an API
export {
GetRenderPass, // rpname => RenderPass instance
Render, // draw all renderpasses
}
See Guide: Codestyle for conventions that SNA-style apps currently use.
To get around direct dependencies, the Endpoint class allows you to define sets of messages for convenient communication without needing to import a module directly. While not as fast as a direct invocation of code using direct imports, it's useful for signaling more infrequent user-triggered updates. This is supported both for local calls and for network-wide calls, using channels.
// non-working example demonstrating syntax
import { Endpoint, AddMessageHandler } from 'sna-client-services.ts'
const EP = Endpoint();
// define a NET message handler
AddMessageHandler('NET:MY_MESSAGE', data => {
console.log('I received',data, 'and will return { foo }');
return { foo:'bar' };
});
// call a NET message that can exist anywhere (other than the caller)
EP.netCall('NET:SOME_MESSAGE', { foo: 'bar' }).then( data => {
console.log('My message call returned',data);
});
If you want to control the order of operations, the PhaseMachine class allows you to define "PhaseGroups" of "Phases", which are defined as soon as the program starts up. A method is provided to "hook" into a particular machine and phase name, so any module in an SNA app can be guaranteed that a chunk of code is run after previous operations has completed. This is useful not only for initialization, but also for repetitive game loops. This code is pretty fast which makes it suitable for tight realtime game loops.
// non-working example demonstrating syntax
import { HookPhase, AddMessageHandler } from 'sna-client-services.ts'
// hook into the lifecycle
HookPhase('SNAPP/NET_INITIALIZE', ()=>{
AddMessageHandler('NET:MY_MESSAGE', data => {});
})
HookPhase('SNAPP/APP_READY', StartGameLoop);
// declare game loop
const GLOOP = new PhaseMachine('GLOOP',{
PHASE_RUN: [ 'GET_INPUT', 'UPDATE', 'RENDER' ];
});
function StartGameLoop() {
GLOOP.RunPhaseGroup('PHASE_RUN');
}
If you want better performance or prefer to couple dataflow between modules, the EventMachine class implements a basic pub/sub. It's loosely modeled on the NodeJS EventEmitter class
. Note that this is not the same as a UI event, which contains UI-related context that is not necessary.
// non-working example demonstrating WIP syntax
import { EventMachine } from 'class-event-machine.ts'
import type { EventHandler } from 'class-event-machine.ts'
// define emitter
const EM = new EventMachine('input');
function Subscribe(evtName:string, handler:EventHandler) {
EM.on(evtName, handler);
}
// emit a random event
EM.emit('foo', { text: 'bar' });
export {
Subscribe // subscribe to events from module
}
criteria: modularity, hierarchical, predictability, pluggable
In an SNA app, dependencies between modules are carefully controlled, generally starting with the most primitive to more specialized. The general rule of thumb is import from lower levels, never from peer or higher levels.
A subtler requirement is that higher-level modules should use the semantics of lower-level modules instead of expecting them to directly understand them. For example, a user interface that sends a DOM HTMLElement or EventTarget object to a lower level manager is creating unintended coupling and complexity. It's the responsibility of the higher-level module translate its data downward. As lower-level modules never directly call higher-level modules except through URMessage or EventMachine
Operating system modules that are built-in platform APIs, npm packages, and depenedency-free URSYS data structure classes. The URSYS CORE modules in the _ur
directory are included as an os-level module.
- different platforms (e.g. webapp versus server) may have different modules available
The critical runtime data settings that have to be shared with other modules are stored in layer 1. This data is typically shared using a common format when possible.
Examples include tables of strings, utility modules that provide specialized data conversion and error checking, and other standardized data that is used by multiple modules. These are also good places to define concepts and data models, including type declarations.
- if you find you need to share data between two modules, shove that stuff into this layer so you can import it as a common dependency
Service modules provide services built from libraries and URSYS core. These include URSYS ADDONS which are in the _ur_addons
directory.
In general, they implement broad services that are not application-specific as a management module with a service API. Examples include managing "collections of data", "network connectivity", "configuration" and "state". Higher-level modules import these services to derive more specialized functions and workflows.
- URSYS ADDONS are included in this layer when they provide a programmatic module interface
There are the application-specific modules that fall into five general classes. They all make use of the four types of SNA programmatic invocation patterns.
- AppState modules are used for sequencing application operations, display and control modes, and centralizing state that is related to how the application looks and behaves. This is more primal that DataCore or AppCore modules. This is typically a global module or entry-point for related modules. This centralizes the logic for broad control of the SNA application for (1) what screen is being shown, (2) what input is allowed, (3) providing management feature for sequences of operations that take time or require error handling and (4) handling sets of selections, highlighted items, focus, etc. Some AppState might be persisted (e.g. 'last loaded file') so it's available on restart of the SNA app. Note that AppState should not use UI-specific data structures to maintain adherence to hierarchy and modularity concerns as UI (through ViewCore) is a much higher-level module.
-
DataCore modules are the lowest-level module, managing runtime data collections. They are intended to be universally importable by subsequent modules in this list. They handle model data and directly-derived model data (e.g lookup tables). Most critically, they are the "single source of truth" and are therefor the only source of notifications related to data updates through URSYS Messaging or an EventMachine. Datacores are also are responsible for implementing data persistence, making use of LAYER 2 services. The SNA module naming convention is to use the prefix
dc
as indc-agents.ts
. It's similar to the "Model" of the MVC or MVVM pattern. -
Feature modules implement application subsystems in as standalone a manner as possible. They are generally data-driven and have specific datacore modules associated with them. The SNA convention is to give the module a unique system-level noun (e.g.
renderer
,screen
) with the optional suffix-manager
or-mgr
depending on whether the module represents an Object or manages sets of Objects of the same class. -
AppCore modules implement code that can be triggered by user actions, implementing and managing operations. This is where application state and application logic live. An AppCore can import multiple Datacore and Feature modules to control them. The SNA naming convention is to prefix with
ac
as inac-agents.ts
. Writing an AppCore module is not easy, because you must design the API such that it doesn't conflict with other AppCores.
Introducing ViewCore! Previously, only AppCore modules were allowed to talk to both View components (e.g. React) and DataCore. However, this made them too unwieldy. ViewCore replaces this. The main reason for this separation is to allow the SNA app to run headless; up to AppCore, everything is pure data and data manipulation. Different ViewCore and View modules can implement different rendering approaches without changing previous layers.
-
ViewCore helper modules implement the user interface glue code that mediates UI-initiated actions with AppCore modules. ViewCore modules are the only modules that can talk up and down as a "bridge" between (1) AppCore and (2) View. It's similar to the "Controller" in the MVC pattern. ViewCore modules maintain their own UI-specific states and hook into View, so complex state changes can be centrally managed outside of the View components. which is derived from other data sources independently because UI frameworks convert data into technology-dependent values (e.g. data is a number, but the UI uses strings to display and receive input). To do its work, ViewCore (1) subscribes itself to updates from either DataCore or AppCore modules to know when to render and (2) converts user events into data that is dispatched as an operation to AppCore, which talks to relevant DataCore and state modules that fire a notification that triggers (1). The SNA naming convention is to use the
vc-
prefix. - View modules are the UI- or platform-specific modules. They interface only with their ViewCore helper modules for data-related input and rendering, and AppState for state-related conditional input and rendering. In the ideal case, a View (e.g. UI component like React) imports a ViewCore to augment all its operations as a "helper" so the component code is as uncluttered as possible (e.g. a thin client). A single ViewCore module can serve multiple components. There is no SNA naming convention for View modules, as UI frameworks tend to have their own.