Skip to content

Overview of SNA

DSri Seah edited this page Dec 11, 2024 · 11 revisions

What is 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.

Structure of an SNA Application

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.

1. First-time package.json configuration

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 byts-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.

2. The AppBuilder Script

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.

3. Adding Main App Entry Points

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:

  1. hooks itself to the APP Lifecycle
  2. calls PreConfig on all registered SNA Components, which gives each module access to the GlobalConfig set earlier.
  3. calls PreHook on all registered SNA Components, which gives each module a chance to add themselves to the APP Lifecycle.
  4. runs all the defined phases in the APP Lifecycle, one after the other

After this, the application is running.

4. More about Lifecycles

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.

5. Declaring SNA Component Modules

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.

6. Bootstrapping an SNA App

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

GAME BOOT (main entrypoint)

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();
})();

GAME LAUNCH

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.

Using Multiple Lifecycles

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.

Chaining Lifecycle Initialization

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.

  1. @game-boot.ts loads game-launch.ts and registers it as an SNA_Component before calling SNA.Start().

  2. 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 at SNA/APP_CONFIG and SNA/APP_RUN.

  3. The game-launch module calls the game master control program (MCP) defined in game-mcp.ts. This is the system that implements the second lifecycle SNA_GAME, and provides its own method to hook into it HookGamePhase().

    • in app lifecycle SNA/APP_CONFIG, game-launch invokes MCP.Init()
    • in app lifecycle SNA/APP_RUN, game-launch invokes MCP.Start()

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 lifecycle SNA
  • game-launch.ts loads all the dependency system modules for the game before initializing and starting the game through game-mcp.ts
  • game-mcp.ts implementsthe secondary lifecycle SNA_GAME, through which dependency system modules can hook into directly

NUANCES

  • 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, and visual 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.

Runtime Operation

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);
}
Clone this wiki locally