Skip to content

Files

Latest commit

 

History

History
220 lines (178 loc) · 10.5 KB

CONTRIBUTING.md

File metadata and controls

220 lines (178 loc) · 10.5 KB

Contributing to Black Iron

Table of Contents

Setting up

Prerequisites

Note: If postgresql installed via homebrew, make sure to run /usr/local/opt/postgres/bin/createuser -s postgres.

You can use asdf to install elixir and erlang (ensure the necessary build deps are installed first):

asdf plugin add nodejs
asdf plugin add erlang
asdf plugin add elixir
KERL_BUILD_DOCS=yes asdf install # will install erlang (w/ docs), elixir, and nodejs

Application setup

Just a couple more commands and we're all set:

  • mix local.hex to install hex package manager
  • mix archive.install hex phx_new
  • If using docker postgres: docker compose up -d
  • mix setup to install deps, create the database, install JS deps, and build JS assets
  • mix phx.server to run the server

Architecture

Stack

Backend

Frontend

  • Plain old HTML + CSS (only preprocessing for CSS is bundling/minification)
  • Lit for dynamic components
  • TypeScript

Concepts

Black Iron does is really two "apps" in a trenchcoat:

  • The "site", which is where you can browse through published campaigns, manage your account/settings, view published campaign stats/comments, etc. This also has the home page which is the entry point to the entire site.
  • The "app", which is the offline-first game part of the site. This is where you actually play your campaigns.

Both of these have somewhat different considerations.

The "site" does not need very much dynamic content, and is largely rendered server-side through Phoenix's HEEx templates. Dynamic content is usually handled through htmx, or through a (small!) sprinkle of JS/TS. The site is supposed to be lightweight and fast, and these things are prioritized. As such, there's a separate entry point for the site section's JavaScript, which only loads the very minimum client-side code needed, even if it might use a few lit components.

The "app" side, on the other hand, is a heavier client-side application that's meant to work fully offline and sync either live, or occasionally, largely through Phoenix Channels. This is where most of the Lit components live, and where most of the client-side JS exists. This is also where the game logic lives, although the server will do some validation on sync, and where all the game state is stored (in IndexedDB).

PWA/Offline-first

Black Iron is also meant to be a PWA, and as such, uses Service Workers to cache as much as possible.

For the "app" side, this means the "shells" for all game pages are cached, and game-specific content is loaded client side only when offline (see JS Considerations and SSR). Essentially, the entire game is "installed" on the user's device when they first visit the app. For dynamic pages, a "default" shell page is precached for offline use for each page.

For the "site" side, only a subset of the site is cached, including the "shells" for some key pages that might have dynamic content. Everything else is requested as usual from the server.

When offline, trying to visit pages that aren't already cached will send you to a "you are offline" page, no matter what URL you try to visit.

Navigation

All navigation in Black Iron is done through standard browser navigation. That is, Black iron is an "MPA", not an "SPA". This way, we get to keep all the benefits of a traditional website, like deep linking, and the back button just working, without having to load (and manage) additional JavaScript.

Even the "app" side is an "MPA": it's broken up into multiple pages, each of which loads the game-specific content dynamically. Changing things like which campaign is currently active is done through the URL (e.g. /campaigns/my-cool-campaign/pcs/bob). When you're online, these pages are largely generated server-side. When offline, a "default" shell is loaded, and JS itself looks up data in indexeddb to try and fill in the contents.

We use the View Transitions API to make the transition between pages as smooth as possible.

JS Considerations and SSR

We use TypeScript for all client-side code, and Lit for all client-side components. Components should be small and focused, preferring to use server-side components and rendering whenever possible.

We generate as much html/css as possible server-side, and try to keep our JS payloads as small as possible. Server-side rendering, or SSR, is done in three layers:

  1. Phoenix controllers render a dynamic page with as much of the content in regular light DOM as possible. This content can be styled with our global CSS styles, and does not require JS to render/function.
    • This uses Phoenix HEEx templates and Components.
    • Within these templates, we can insert Lit components for things that will absolutely need dynamic, client-side/JS behavior or will otherwise have to do something special to function while offline.
  2. Once Phoenix renders these templates, they're passed through the Plug system, which eventually invokes a Lit server-side renderer.
    • This renderer takes the template, loads all existing components, and pre-renders Lit components as far as server-side work will allow.
    • In components, this behavior can be controlled with isServer.
  3. Finally, all this server-side-rendered content is sent to the client, and Lit components will be "hydrated" after all the other content and JS is loaded.

Additionally, for dynamic pages, there's an extra step: the client-side Service Worker will request "shell" versions of these pages. All dynamic page controllers should be able to handle these requests, and support returning these "shell" pages without any dynamic content. These shells will later be used by the client-side whenever the application is online, and the only way to get game data is through IndexedDB. "Shell" pages are requested by having their params set to __paramName (e.g. a route that looks like /campaigns/:campaign_id would have a shell request like /campaigns/__campaign_id). These shell pages are updated in the background over time.

As a general rule, we operate on "the less JavaScript, the better". Dependencies should be few and far between, preferring to use built-in browser features whenever possible. If a dependency is needed, it should be small and focused, and not introduce a lot of overhead. Obviously, some things in offline apps are just going to bring in some bulk and that's ok, but whenever we have a choice between two things, code size should be a significant consideration in their evaluation.

To minimize JavasScript, we should err on the side of having components be Phoenix-based, and only use Lit components when absolutely necessary: even if they're server-side rendered, their JavaScript definitions still needs to load/hydrate, and are still shipped as part of our .js bundles.

To see what kind of weight a dependency brings in, you can use BundlePhobia.

Folder Structure

  • assets/ - Frontend code
    • css/ - CSS files
      • components/ - CSS for (usually server-side) components
      • app.css - main css entrypoint
      • theme-*.css - variables for themes
      • variables.css - non-theme-specific variables
        • NOTE: While regular styles don't leak into shadow DOM, --variables do, so we can use this for things we need to have consistent styling for.
    • js/ - JS/TS files
      • components/ - Toplevel lit components
      • app.ts - entry point for the app side (the game, the offline-first part)
      • site.ts - entry point for the site side (the entry point and rest of the site)
      • service-worker.ts - service worker implementation for the whole app
      • black-iron-app.ts - main app class for game state/coordination. Basically a god object.
      • lit-ssr.ts - lit server-side renderer tool
      • Other files are pulled in by one of these.
  • config/ - Configuration files
    • Different configs for dev, prod, and test envs.
    • config.exs - common config
    • runtime.exs - more prod config
  • lib/ - Elixir source code
    • black_iron/ - core business logic. No web stuff here. All modules are prefixed with BlackIron.
    • black_iron_web/ - all web stuff here. Modules are prefixed with BlackIronWeb. Uses BlackIron for any business logic.
  • priv/ - miscellaneous things
    • gettext/ - this is where our (server-side) i18n stuff lives
    • repo/ - migrations, seeds, etc
    • static/ - static files. JS and CSS are compiled into here (but .gitignored)