Skip to content

Instantly share code, notes, and snippets.

@andyjessop
Last active January 5, 2021 19:51
Show Gist options
  • Save andyjessop/a7e1f0ad8019c66347a84abd87e51668 to your computer and use it in GitHub Desktop.
Save andyjessop/a7e1f0ad8019c66347a84abd87e51668 to your computer and use it in GitHub Desktop.

This is a plan of development for the simulator that will make it easier to extend incrementally, whilst keeping our core concepts of a decoupled, framework-agnostic code base.

First, the problems with the current setup for the client:

  1. Our layout is defined in HTML (well, pug), so we only have a single layout at the moment, and our elements service is tied tightly to that layout. This is fine for the simple page that we have currently, but is not flexible and makes multiple layouts or dynamic layouts difficult.
  2. We have all sorts of ideas for being able to record events, play them back, undo/redo, etc. for which a solely event-driven architecture is unsuited.

The classical solution to these, of course, is a monolithic state-driven framework like React or Vue, but those are unsuited to long-lived applications due to their vendor lock-in. cob was created to solve that and is working really well on vs-computer/vs-personalities, enabling us to write code that is not tied to a single framework and is instead service-oriented.

I want to propose an extension to the ideas behind cob to allow for dynamic layouts (in JS) with a client-side router (which we already have), both state-driven and event-driven updates, and keeping the framework-agnostic, highly-decoupled concepts of cob.

Happily, the architecture is pretty familiar, but also has a few key changes. It is uni-directional, but the component update process has a few extra steps in order to aid decoupling:

  1. Events are dispatched from components by calling simple functions exposed by modules.
  2. Modules handle those events, do work, and update the state. These are equivalent to our current plugins (the terminology is to be changed to avoid conflict with chessboard plugins).
  3. A layout component reacts to state changes (or to an event) and defines the page layout (e.g. two panels, or focus mode for performance testing, or multi-board, multi-move list for edge-case testing). The layout exposes root elements for JS components to hook into.
  4. Views are mounted/unmounted into the exposed root elements.
  5. Views are updated according to subscriptions to events or to state selectors.

This setup leaves us with modules that are still highly decoupled, view components that can be written in any framework (or none), updates that can be state- or event-driven, and a familiar uni-directional event-drivent architecture.

Modules

A module can:

  • expose methods for general use,
  • update state by modifying it directly.

They would look something like this:

// modules/plugins/index.ts
export const pluginsModule = createModule(createPlugins);

// modules/plugins/create.ts
function createPlugins() {
  return {
    add,
    initialState: getInitialState(),
    remove,
    getSomething,
  };
  
  function add(state, plugin) {
    const { name, params } = plugin;
    
    if (state[name]) {
      return;
    }
    
    state[name] = plugin;
  }
  
  function getSomething(state) {
    // exposed methods don't have to update the state, freeing them up to act
    // independently from the state.
    
    return something;
  }
}

Views

NB: In the examples that follow, I'm not suggesting the use of any specific framework. I'm using lit-html here only because it's the simplest way to express the core concepts.

Views define mount and unmount methods, as well as a subscribe object, and receive a container at instantiation that allows them to access modules:

import styles from 'views/right-panel';

function createRightPanel(modules) {
  let component;
  const getPlugins = createSelector(state => state.plugins);
  
  return {
    mount,
    name: 'right-panel',
    subscribe: [
      { action: 'someModule.relevantAction', handler: component.update }, // action subscription
      { selector: getPlugins, handler: component.update }, // state subscription
    },
    unmount,
  };
  
  function mount(el, state) {
    component = createComponent(el, state);
  }
  
  funtion unmount(/* el, state */) {
    component.destroy();
  }
}

function createComponent(root, state) {
  update(state);
  
  return {
    destroy,
    update,
  };
  
  function destroy() {
    root.innerHTML = '';
  }
  
  function getPluginTemplates(plugins, remove) {
    return plugins.map(plugin => html`
      <div class=${styles.plugins}>
        <div>${plugin.name}</div>
        <button onclick={remove(plugin.name)}>Remove</button>
        <div>${pluginOptions(plugin)}></div>
      </div>
    `);
  }
  
  function getTemplate(plugins) {
    const { add, remove } = modules.plugins;
    
    return html`
      ${getPluginTemplates(plugins, remove)}
      
      <div>
        <!-- e.g. adding clocks plugin -->
        <button onclick={add('clocks')}>Add Clocks Plugin</button>
      </div>
    `;
  }
  
  function update(newState) {
    render(getTemplate(newState), root);
  }
}

Layout

The layout is a special module that defines the overall app layout and provides mount points for the views. It might look something like this:

export function createLayout(el, state) {
  const getRoute = createSelector(state => state.router.route.name);
  
  return {
    update,
  };

  function template(name) {
    switch (name) {
      case 'multi':
        return html`<div data-view-name="multi-games"></div>`;
      case 'focus':
        return html`<div data-view-name="main-game"></div>`;
      default:
        return html`
          <div data-view-name="nav"></div>
          <div data-view-name="left-panel"></div>
          <div data-view-name="main-game"></div>
          <div data-view-name="right-panel"></div>
        `;
    }
  }

  function update(state) {
    const route = getRoute(state);
    
    render(template(route), el);
  }
}

Notice that layout exposes data-view-name, these are the views that are then mounted/unmounted.

State

The implementation of the state could be very simple really, just an object that we use Object.assign to update. However, I'm going to recommend that we use the Redux ToolKit, which is official Redux, but modernised to address the concerns of the community with respect to boilerplate, switch statements, overuse of spread operator making code look ugly, etc.

Please give the docs a quick read if you're not already familiar with them. The reasons that I am recommending this are:

  1. It is very small, and very simple. There is no magic involved in Redux - you could read and understand (even recreate) the code in an afternoon.
  2. It is battle-hardened like no other state-management library.
  3. It is framework-agnostic in the sense that it can work with any view library.
  4. It works well with TypeScript.
  5. It has very strong integration with dev tools, making development of the chessboard simpler.
  6. There is community-maintained middleware for difficult but useful features like undo-redo.

Summary

These proposed changes will keep the simulator decoupled and framework-agnostic, but will also allow us to be really flexible with how we improve its functionality over time.

Please let me know what you think below.

@tShwed
Copy link

tShwed commented Dec 10, 2020

This is good! I was a big fan of cob when working on vs-personalities. I think the implementation of Redux is smart since I think that was one piece of the puzzle with cob that felt missing. Probably best to commit to one framework when we're creating the components, both for consistency and bundle size. Svelte is what we're already using in the chessboard repo so I vote for that. Since they're single components, if we ever have to switch over it's pretty easy and might be a weeks worth of effort (if it's similar to vs-personalities when we switched from Svelte to Vue)

@andyjessop
Copy link
Author

andyjessop commented Dec 10, 2020

Probably best to commit to one framework when we're creating the components, both for consistency and bundle size

Yep, definitely. It doesn't make sense to use different view frameworks from the outset. I just wanted to clarify that any could be used in this setup, and that in fact if you wanted to change eventually, you could do so incrementally. You're not tied to a monolithic rendering process.

Edit: I also agree with Svelte, for the reason you gave.

@ajhaupt7
Copy link

This is awesome, I'm 100% on board with the approach here. Also think that Svelte is probably smart to stick with. It's been awhile since I've used Redux, but if their core concepts haven't changed, then I support that too. My guess is that we'll refine Views and the Layout controller over time, and they'll evolve a bit, which isn't a bad thing.

@ajhaupt7
Copy link

One question would be: how does this affect the current version of cob? Given that it's in the main repo, do we pull the core out of there and export it in the chessboard? Leaving the plugins and services that already exist where they are.

@tShwed
Copy link

tShwed commented Dec 10, 2020

Maybe web-ui is a good place for cob to live if we're using it in both the main repo and chessboard.

One thing we'll want to prioritize if we use Svelte is to solve the issues Cypress has compiling Svelte components. That's been a headache, especially when trying to test HML stuff

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment