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:
- play it here,
- view the source code,
- 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 ( <p> Score {state.score} </p> ) }
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:
$currentState
: This is the main store that stores the current GameState (score and game grid).$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.$availableMoves
: This store is derived from$nextStates
and just stores aSet
of the possible next moves that would result in a change to the grid (e.g.LEFT
,RIGHT
etc).$status
: This is derived from$currentState
and$nextStates
and stores the current status of the game (eitherplaying
,lost
orwon
).
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 requestMove(moves.LEFT)
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:
$currentState.on( requestMove, (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() "PLAYING" > api.$availableMoves.getState().has(api.moves.LEFT) true > api.requestMove(api.moves.LEFT) > api.$status.getState() "PLAYING" > api.$state.getState().score 0 > api.requestMove(api.moves.LEFT) > api.$state.getState().score 4
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.