2048 with React, Effector, TypeScipt, Deno

Dec 31, 2021

It seems I can't stop myself from churning out alternative implementations of the 2048 game. So, following on from my Eve version and React / XState version, here's yet another written in TypeScript using React with Effector for state management and Deno on transpiling/bundling duties.

You can:

  1. play it here,
  2. view the source code,
  3. or do both on CodeSandbox.

This version is very similar to the previous React version and, apart from porting to TypeScript the core game library (in core.ts) is almost identical. However, whereas the previous version used XState to manage the game state and expose it to React, this version uses Effector.

Read on for my experiences of using Effector and Deno (and specifically the deno REPL)...

State management with Effector

Effector is a library to help with state management in Javascript/TypeScipt apps. It's not tied to any framework but does provide integrations for React (used here) and Vue.

In Effector, state is stored in stores created with createStore:

const $currentState = createStore<GameState>({
  grid: [],
  score: 0

By convention, store variables are prefixed with $. They can contain whatever you want, and, when the store value changes, subscribers to the store are notified.

In React, subscribing to a store is simple:

function SomeComponent() {
  const state = useStore($currentState)
  return (
      Score {state.score}

This component will then update automatically whenever the value of the $currentState store changes.

Normally you will have quite a few stores in your app and the power of Effector comes from its ability to combine these stores in various ways to create new derived stores and generate new events.

For example, in the 2048 implemenation, I use four stores:

  1. $currentState: This is the main store that stores the current GameState (score and game grid).
  2. $nextStates: This store is derived from $currentState — whenever that store changes, the possible next moves and resulting GameStates are calculated and stored in this store.
  3. $availableMoves: This store is derived from $nextStates and just stores a Set of the possible next moves that would result in a change to the grid (e.g. LEFT, RIGHT etc).
  4. $status: This is derived from $currentState and $nextStates and stores the current status of the game (either playing, lost or won).

Creating a derived store is straightforward: For example, the $status store is setup as follows:

const $status = combine(
  $currentState, $nextStates,
  (state: GameState, nextStates: Map<Move, GameState>) => {
    if (hasWon(state.grid)) {
      return Status.won
    if (nextStates.size === 0) {
      return Status.lost
    return Status.playing

Whenever the $currentState and/or $nextStates stores change, the supplied function is called with the current values of these two stores and the result becomes the new value of the $status store. In this case, the function determines if the player has won (the current game grid has a 2048 tile in it), lost (no next states are available) or still playing.

Events are equally straightforward in Effector. There's no need to pass around strings Redux-style, you just use createEvent to create an event function and call that function to dispatch the event:

// Create an event
const requestMove = createEvent<Move>()

// Dispatch when needed by calling the returned function

As with stores, Effector provides a variety of ways to react to events. For example, if you just want to update a store when an event occurs, you use the store's on method:

  (state, move) => {
    // Calculate new state from current store state and the event
    // move parameter
    return newState 

I'm quite impressed by Effector so far. This 2048 implementation is obviously a very small example (see game.ts) but it seems to me it should scale quite well to larger projects.

It's pretty easy to learn and the API provides a relatively small number of functions, most of which allow events and stores to be used interchangably as parameters which allows them to be combined easily to create complex event processing pipelines.

I also experimented using Deno for this project which I'll discuss next.

Deno and the REPL

Deno is an alternative to Node and provides a Javascript runtime with built-in support for TypeScript, a (relatively simple) bundler, and the stand-out feature from my point of view, a REPL.

Typically you'd be using Deno for applications that contain at least some server-side component, but since it includes a bundle command it can be used to transpile and combine simple TypeScript applications wihout the need to use webpack or similar.

If you're used to languages such as Python or Clojure which provide a REPL or interactive prompt that allows you to interact with your code as you develop it, then you'll probably be pleased to know that Deno provides a similar experience via the deno repl command:

deno repl --config '../deno.json' --import-map 'import_map.json'

Once running, you can use this to import and interact with your own code or you can import third-party libraries either via a CDN URL (e.g. https://cdn.skypack.dev/effector?dts) or via an alias if you have provided an import-map that can be used to resolve it.

Here's an example session working with the 2048 code:

Deno 1.17.0
exit using ctrl+d or close()
> import { makeApi } from "./game.ts"
Check file:///home/mike/workspace/effector-2048/src/game.ts
> let api = makeApi(4)
> api.$status.getState()
> api.$availableMoves.getState().has(api.moves.LEFT)
> api.requestMove(api.moves.LEFT)
> api.$status.getState()
> api.$state.getState().score
> api.requestMove(api.moves.LEFT)
> api.$state.getState().score

What I haven't found out yet, is whether there is a way of re-loading a module after you have changed it. I can re-issue an import command but this doesn't appear to re-evaluate the module, so I have to close and restart the REPL to see changes.

Nevertheless, I find this a great tool for experimenting and testing things out during development.