-
Notifications
You must be signed in to change notification settings - Fork 2
Overview of SNA
SNA is a modular realtime webapp system built on top of the URSYS core classes and build system. Its name is inspired by XNA, a modular realtime game architecture that was available on Microsoft Windows for the C# programming language.
So what's the big idea?
- URSYS implements the core build tool and utility modules, while SNA builds a more opinionated structure on top of it.
- The big win of SNA is that it becomes a much easier way to start up my opinionated message-based client-server web app architecture.
- The second win of SNA is that it's designed for flexible event-driven development that is suitable for game-like applications.
The goal of SNA is to simplify the deployment of webapps being developed within the root directory of a containing URSYS installation.
Note
See SNA Module Patterns and FAQ: SNA pages for additional information.
Tip
A SNA Component is a special module that knows how to participate in the SNA lifecycle. Any code module that does export default SNA.NewComponent(...)
at the end of a SNA Component. If this is not present, then it's a regular javascript module. At times module and component may be used interchangeably; future versions of the library will likely change the name to avoid confusion.
An SNA App lives in an URSYS monorepo in a directory that begins with sna-
, for example sna-myproject
. URSYS ignores these directories in its source control setup. You develop it independently of URSYS, or from within URSYS so you can access the full source control.
The SNA build system requires your source files conform to this directory structure:
sna-underworld/ - project directory in URSYS root
@build-underworld.mts - CLI: node build
_public/ - built web app directory
app-source/ - browser and server source
@app-main.ts - entrypoint for the web app
server-main.mts - entry point for support server
app-static/ - asset directory copied "as-is" into _public
_datapack/ - dataset binary assets (not kept in repo)
index.html - index page that includes js/bundle.js
package-lock.json
package.json
tsconfig.json - typescript config for path aliases
underworld.code-workspace - visual studio code project
Let's go through each file one-by-one, in the order they are used to create a new SNA app.
To run an SNA project, you need to ensure that the URSYS core is compiled before starting the app building process server. This is accomplished with two scripts and a dependency defined in your package.json
:
{
"scripts": {
"core": "ts-node-esm --transpile-only ../_ur/npm-scripts/@build-core.mts",
"dev": "npm run core && ts-node-esm --transpile-only ./@build-underworld.mts",
},
"dependencies": {
"@ursys/core": "file:../_ur"
}
The first script core
uses ts-node
to build the core libraries, which is referred to by the @ursys/core
dependency. The second script always runs the core library builder and then runs the @build-underworld.mts
AppBuilder script.
Note
The .mts
extension means "typescript moduleand can be executed by
ts-node`, as can be seen in the script example above. This is an ESModule format that has Typescript code inside, so it needs to be invisibly transpiled it regular JAvascript before passed on to the vanilla NodeJS instance.
A general convention in URSYS is using @
to designate entrypoints and framework scripts. In this example, @build-underworld.mts
is a build script that uses SNA to both build and serve the application using a development server:
import PATH from 'node:path';
import {SNA} from '@ursys/core';
const sna_project_dir = PATH.dirname(process.argv[1]);
await SNA.Build(sna_project_dir); // builds then serves webapp
The heavy lifting of building and serving the app is completely handled through SNA.Build(project_dir)
, using esbuild
to create the web app bundle bundle.js
and then starting up the URSYS AppServer module. The AppServer provides both Hot Module Reload and URSYS Messaging for implementing client-server-peer communication.
To start the script, use npm run dev
and you'll see the following output:
[insert image of npm run dev terminal output]
The build system reports what entry points are were found using yellow text. It also reports what the webserver URL to use to look at the web application.
Typescript source files are all located in the app-source
directory. Files that are in the "top level" of this directory are considered entry points for either esbuild
's bundler or an add-on to the AppServer. Consider this scenario:
. app-source/
client-modules/
dom-utils.ts
game-loop.ts
store.ts
@app-main.ts - entrypoint for the web app
The SNA AppBuilder considers any .ts
file in the app-source
root to be an entry point to pass onto esbuild
. It does not scan subdirectories; this is where you can put your dependent modules for import into the main entry point.
Here's an example of what might go into @app-main.ts
, the entry point for a web application.
import { SNA } from '@ursys/core';
(async () => {
LOG(...PR('Initializing Web App'));
const dataURI = 'sri.org:bucket-1234/sna-app/project-one';
const datasetMode = 'sync';
// Set the global configuration object
SNA.GlobalConfig({ dataset: { uri: dataURI, mode: datasetMode } });
// Register all SNA components
SNA.RegisterComponent(SNA.MOD_DataClient);
// Start the SNA App Lifecycle
await SNA.Start();
})();
This example sets the "global configuration" object and then registers a built-in component called MOD_DataClient
before calling SNA.Start()
. The Start method does several things:
- hooks itself to the APP Lifecycle
- calls
PreConfig
on all registered SNA Components, which gives each module access to the GlobalConfig set earlier. - calls
PreHook
on all registered SNA Components, which gives each module a chance to add themselves to the APP Lifecycle. - runs all the defined phases in the APP Lifecycle, one after the other
After this, the application is running.
So far, the boilerplate has been very minimal, but in real use we'd have to load and initialize many modules in a particular order, establish a network connection from client to host server, and then render something to the screen. Rather than using the Web App Entry Point to import and initialize these modules, SNA uses the URSYS Lifecycle Hook System instead. Modules then use the hook system to define when they should run in context of the overall life of the app.
SNA is built on URSYS so it automatically has the so-called UR hook set. Here's what it looks like:
PM = new PhaseMachine('SNA', {
PHASE_BOOT: [
'APP_PAGE', // app initial page load complete
'APP_BOOT' // for minimal initialization of data structure
],
PHASE_INIT: [
'DOM_READY' // the app's initial page has rendered fully
],
PHASE_CONNECT: [
'NET_CONNECT', // start the network connection
'NET_AUTH', // hook for authentication setup
'NET_REGISTER', // hook for registration info
'NET_READY', // ursys network is active and registered
'NET_DECLARE', // hook for declaring messages to URNET
'NET_ACTIVE', // system is listen for messages
'NET_DATASET' // hook for dataset connection
],
PHASE_LOAD: [
'LOAD_DATA', // load data from server
'LOAD_CONFIG', // load configuration
'LOAD_ASSETS' // load assets
],
PHASE_CONFIG: [
'APP_CONFIG', // app configure based on loaded data, config, etc
'APP_READY' // app is completely configured
],
PHASE_RUN: [
'APP_RESET', // app sets initial settings
'APP_START', // app starts running
'APP_RUN' // app is running (terminal phase)
],
...
The PhaseMachine executes each of these "phases" in order, waiting for each phase to complete before moving to the next one. The example above synchronizes the startup from time of webapp start. Earlier phases configure and acquire data, with subsequent phases able to rely on those actions having completed. The server also implements its own PhaseMachine, imilar in how each phase relies on the completion of the phase before it. The activities
PM = new PhaseMachine('SNA', {
PHASE_INIT: [
'SRV_BOOT', // boot the system
'SRV_INIT', // allocate system data structures
'SRV_CONFIG' // configure the system
],
PHASE_LOAD: [
'LOAD_INIT', // initialize data structures
'LOAD_FILES', // load data from server
'LOAD_CONFIG' // finalize data
],
PHASE_CONNECT: [
'EXPRESS_INIT', // express allocate data structures
'EXPRESS_CONFIG', // express add middleware routes
'EXPRESS_READY', // express server is ready to start
'EXPRESS_LISTEN', // express server is listening
'URNET_LISTEN' // ursys network is listening on socket-ish connection
],
PHASE_RUN: [
'SRV_START', // server process start hook
'SRV_RUN' // server process run hook
],
PHASE_READY: [
'SRV_READY' // server is ready to run
],
...
"Phase Hooks" are used by modules to connect to one of the defined phases through the use of SNA.HookPhase(selector)
, where selector
is the name of the machine followed by a slash and then the name of the phase group or one of its sub phases.
SNA provides the HookPhase(hookSelector, callback)
method. The hookSelector
is a string formatted as machine/phase
. The machine
parameter is the name of the PhaseMachine you want to hook into, and phase
is either a phasegroup or phaseid defined in that particular PhaseMachine. For example, a web client module will call SNA.HookPhase('SNA/LOAD_ASSETS', MyHookFunction)
in its PreHook()
function.
To participate in the SNA Lifecycle, a module must export an instance of the SNA_Component
support class. For example, the game launcher is an SNA_Component that declares itself as follows.
export default SNA.NewComponent('my_component_module', {
AddModule: SNA_AddModule, // needs to register additional modules
PreConfig: SNA_PreConfig, // during app init, get global config obj
PreHook: SNA_PreHook // needs to register lifecycle hooks
});
Note
In the above example, SNA_AddComponent(f:Function), SNA_PreConfig(void)
and SNA_Prehook(void)
are all function callbacks that you provide.
Here's the complete interface for declaring an SNA Component:
interface SNA_ComponentProps {
AddComponent: addFunction => void, // needs to register additional modules
PreConfig: () => void, // during app init, get global config obj
PreHook: () => void, // needs to register lifecycle hooks,
Subscribe: (evt, notifyCB) => void, // subscribe to module events
Unsubscribe: (evt, notifyCB) => void // unsubscribe to module events
}
Once a module has exported a default SNA definition, it can participate in the SNA lifecycle-driven component architecture.
The main entry point usually is limited to selecting which components to load with what global config. The application-specific logic runs through hooks to the lifecycle, typically the DOM_READY
, LOAD_ASSETS
, and APP_CONFIG
phases before APP_START
is invoked. This ensures that by the time a phase finishes, all modules hooked to that phase have completed. For example, as APP_CONFIG
occurs later than LOAD_ASSETS
, you can be assured that any code that runs in APP_CONFIG
can count on all assets being loaded. You can also be sure that by the time APP_START
is invoked, APP_CONFIG
has already completed for all other modules so the system as a whole is ready to run.
Typically there is a control module and several independent state modules. For example, this SNA-based game uses game-boot.ts
for its main app which loads several control+state modules located in a client
subdirectory.
app-source/
@game-boot.ts
client/
game-launch.ts
game-mcp.ts
game-state.ts
The @game-boot.ts
file is the entry point and looks like this:
import { SNA } from '@ursys/core';
import * as MOD_Launcher from './client/game-launch';
(async () => {
SNA.GlobalConfig(cfg);
SNA.RegisterComponent(MOD_Launcher);
await SNA.Start();
})();
The game-launch.ts
file is what loads all the components needed to run the game. It is itself an SNA_Component, as are render-mgr
, texture-mgr
, visual-mgr
, and games/05-test-controls
imports.
import { SNA } from '@ursys/core';
import * as MCP from './game-mcp.ts';
import MOD_RENDER from './engine/render-mgr.ts';
import MOD_TEXTURE from './engine/texture-mgr.ts';
import MOD_VISUAL from './engine/visual-mgr.ts';
import GAME from './games/05-test-controls.ts'
function SNA_AddModule({ f_AddModule }) {
f_AddModule(MOD_RENDER);
f_AddModule(MOD_TEXTURE);
f_AddModule(MOD_VISUAL);
f_AddModule(GAME);
}
function SNA_PreConfig(cfg: DataObj) {
const { data } = cfg;
// do something here
}
function SNA_PreHook() {
SNA.Hook('APP_CONFIG', async () => await MCP.Init(),
SNA.Hook('APP_RUN', async () => await MCP.Start()
}
export default SNA.DeclareModule('Launcher', {
AddModule: SNA_AddModule,
PreConfig: SNA_PreConfig,
PreHook: SNA_PreHook
});
In the GAME BOOT section prior, the game-boot.ts
file is registering game-launch.ts
as an SNA_Component. After the SNA.Start()
method is called, game-launch.ts
has its AddModule, PreConfig, PreHook methods called so it too can add its own modules and participate in the lifecycle.
In the case of our game example, we have an additional client lifecycle that implements the game loop. It looks like this:
PM = new PhaseMachine('SNA_GAME', {
GAME_INIT: [
'INIT', // game initialization
'LOAD_ASSETS', // game asset loading
'CONSTRUCT', // game object construction
'START' // game start
],
LOOP_BEGIN: [
'CHECKS', // game checks
'REFEREE' // game referee decisions
],
LOOP_CALC: [
'INPUT', // player, world inputs
'UPDATE', // autonomous updates (timers)
'CONDITIONS' // game trigger checks
],
LOOP_THINK: [
'THINK', // individual piece AI
'OVERTHINK', // group manager AI
'EXECUTE' // deferred actions, if any
],
LOOP_RENDER: [
'PLAY_SOUND' // play sound effects
'DRAW_WORLD', // threejs draw world
'DRAW_UI', // draw UI elements over world
],
END: [] // game over
Note that the name of this phasemachine/lifecycle is SNA_GAME to distinguish it from SNA, the application lifecycle.
The basic premise of SNA is to let system modules configure themselves by inserting themselves at the right place in a second lifecycle phase controller SNA_GAME (defined above). This requires that the second phase controller is initialized at the right point of the first lifecycle. The following example will try to show this.
To start, here's the directory structure of a hypothetical SNA-based game. Execution starts with game-boot.ts, which is the entrypoint for the bundler.
app-source/
@game-boot.ts webapp entry point
imports game-launch.ts
registfolloweders game-launch as SNA module
client/
game-launch.ts imports game-01.ts, render.ts,
texture.ts, visual.ts
imports game-mcp as as MCP
registers game, render, texture, visual as SNA modules
hooks 'SNA/APP_CONFIG' to call MCP.Init()
hooks 'SNA/APP_RUN' to call
game-mcp.ts the SNA_GAME PhaseMachine controller (master control)
(dependency free, can be imported by any module)
game-state.ts the holder of common game state (timers, etc)
(dependency free, can be imported by any module)
engine/
render.ts renderer (hooks 'SNA_GAME/INIT', 'SNA_GAME/DRAW_WORLD')
texture.ts texturemgr (hooks 'SNA_GAME/LOAD_ASSETS')
visual.ts spritemgr (hooks 'UPDATE')
games/
game-01.ts game setup (hooks 'SNA_GAME/CONSTRUCT',
'SNA_GAME/START', 'SNA_GAME/UPDATE')
When this game is first loaded into a web browser, the HTML index file loads the bundle.js file which uses game-boot.ts as its entry file, as this is the only .ts
file in the app-source
directory.
-
@game-boot.ts loads game-launch.ts and registers it as an SNA_Component before calling
SNA.Start()
. -
When game-launch.ts is registered, it hooks into the overall SNA Lifecycle system so it can then load game system modules for the game. Like
game-boot.ts
, it is hooking into the app lifecycle atSNA/APP_CONFIG
andSNA/APP_RUN
. -
The game-launch module calls the game master control program (MCP) defined in
game-mcp.ts
. This is the system that implements the second lifecycleSNA_GAME
, and provides its own method to hook into itHookGamePhase()
.- in app lifecycle
SNA/APP_CONFIG
, game-launch invokesMCP.Init()
- in app lifecycle
SNA/APP_RUN
, game-launch invokesMCP.Start()
- in app lifecycle
At this point, MCP.Start()
launches its own lifecycle, and the various system modules that were loaded by game-launch.ts
have already hooked their own routines into the various LOAD_ASSETS, CONFIG, DRAW, and UPDATE methods that are defined in the lifecycle.
To summarize:
-
@game-boot.ts
handles the main app's lifecycleSNA
-
game-launch.ts
loads all the dependency system modules for the game before initializing and starting the game throughgame-mcp.ts
-
game-mcp.ts
implementsthe secondary lifecycleSNA_GAME
, through which dependency system modules can hook into directly
- To share state, any modules can import
game-state.ts
(dependency-free) to access global state like frame rate information, important string constants, etc. - The main system modules
render
,texture
, andvisual
are also top level components that export a variety of methods (e.g.MakeSprite()
from the visual module). - The
game-01
module is the "stage manager" that creates the specific elements (e.g. ship sprites, background) and keeps them up-to-date. It interacts with the system modules to configure and create various assets related to the gameplay and presentation.
After everything has been set up, the sna-mcp module initializes the game by first executing the phase group SNA_GAME/INIT
, followed by running the game loop-related phase groups SNA_GAME/LOOP_BEGIN
through SNA_GAME/LOOP_RENDER
. Here's an example of what the sna-mcp's Start()
function looks like:
import * as GSTATE from './game-state.ts';
async function Start() {
// first run the INIT phase group
await RunPhaseGroup('SNA_GAME/INIT');
// set up game timer
const { frameRate, frameDurMS } = GSTATE.GetTimeState();
GAME_TIMER = setInterval(async () => {
if (frameRate > 0) {
await RunPhaseGroup('SNA_GAME/LOOP_BEGIN');
await RunPhaseGroup('SNA_GAME/LOOP_CALC');
await RunPhaseGroup('SNA_GAME/LOOP_THINK');
await RunPhaseGroup('SNA_GAME/LOOP_RENDER');
}
}, frameDurMS);
}