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:
- Our layout is defined in HTML (well,
pug
), so we only have a single layout at the moment, and ourelements
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. - 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:
- Events are dispatched from components by calling simple functions exposed by modules.
- 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 chessboardplugins
). - 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. - Views are mounted/unmounted into the exposed root elements.
- 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.
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;
}
}
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);
}
}
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.
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:
- 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.
- It is battle-hardened like no other state-management library.
- It is framework-agnostic in the sense that it can work with any view library.
- It works well with TypeScript.
- It has very strong integration with dev tools, making development of the chessboard simpler.
- There is community-maintained middleware for difficult but useful features like undo-redo.
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.
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 withcob
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)