Artur Morys - Magiera


Game Of Life


Sound grid visualization 🎶

Source code  

The extracted code is publicly available on GitHub under the MIT license.

A few words 📋

The game presents a PoC that was fun to make, utilizing state-of-the-art technologies available. The renderer used is WebGL, with three.js's abstraction on top of it. As the website is written in React, the game scene is built up declaratively in JSX using @react-three/fiber and finally, it utilizes Tone.js to dynamically synthesize polyphonic sound based on the quantity of alive cells in each sector of the map. All components displaying equations on this page use react-latex & katex for rendering LaTeX code.
The logic of the game is classical Conway's Game of Life rules. To optimize performance, the logic was implemented in Rust that is compiled to WASM with wasm-bindgen. The renderer, as described, is a React component utilizing three.js using the @react-three/fiber React abstraction. Moreover, all cells are just THREE.instancedMeshes so as to be performant enough. For a slightly more artsy experience, I also created a ‘vanishing’ effect for cells that were alive the round before, but are dead now - for just one render, they are semi-transparent (or, more precisely, ‘more transparent’ than cells being currently alive), making a pleasant animation effect.
The arena (map) is generated dynamically by dividing it into a grid and placing items picked from a set of pre-defined structures (e.g. blinker, pulsar, beacon, tumbler, queen bee) that have a pre-set probability weight index using rand::distributions::WeightedIndex that differentiates the probability of picking a specific structure. The templates for these structures are defined as 2D matrices and cached to be generated just once with the once_cell::sync::Lazy wrapper.
This implementation features a controlled frameloop that executes both the game logic and rendering sequentially at a given rate specified in the floating game settings. The game logic is implemented in Map::morph_map_next_round and uses a reference to the THREE.InstancedMesh that is bound with Map::bind_js_cells_instanced_mesh to a RefCell<JsValue>.

Before the game loop starts, the WASM module is initialized, bound to the JS objects (THREE.InstancedMesh instance and cell colors for cell state lookup table object), the THREE.InstancedMesh is populated with a proper quantity of instances, for each of them a static transform is set to appropriately position them on the map, and THREE.InstancedMesh.setColorAt(0, ...) is called to initialize the THREE.InstancedMesh.instanceColor.needsUpdate property (as THREE.js requires it to be done).

Each game round, the Rust/WASM game logic:
  • Mutates the map. For a nicer look, apart from Cell::Dead and Cell::Alive, there exist 3 levels of vanishing: Cell::Vanishing{1,2,3}. Each of these Vanishing types, however, is treated just as Cell::Dead in game logic.
  • Sets proper colors in the @react-three/fiber JS-initialized THREE.InstancedMesh instance by calling its setColorAt method via the bound object ref directly from WASM code using js_sys::Function::call2 (to be specific: also with wasm-bindgen autogenerated code glue).
  • Informs THREE.js that the scene needs to be re-rendered by setting THREE.InstancedMesh.instanceColor.needsUpdate to true directly from WASM code using js_sys::Reflect::set. The render will be performed right after the game loop updates, since Map::morph_map_next_round is called synchronously from useFrame (which is in fact the abstraction's interface for requestAnimationFrame).
A special feature of this implementation is polyphonic sound generated in a specific manner. The map is divided into cells of an arbitrary-sized grid (for the purpose here, I chose a 3×33 \times 3 grid). Each cell of such a grid contains part of the game map, therefore a checksum of its cells can be calculated - for simplicity, I decided to simply take the sum of cells that are alive.
That sum is afterwards turned into a cyclic index used for picking a note from the circle of fifths, utilizing it's even parity. For an index - the sum of alive cells in a ‘sound grid’ cell SijS_{ij} - with a cell of size x×yx \times y - being:

Sij=p=ix(i+1)xq=jy(j+1)yMpq||S_{ij}|| = \sum_{p = i \cdot x}^{(i + 1) \cdot x}\sum_{q=j \cdot y}^{(j + 1) \cdot y} M_{pq}

where MM is the matrix containing the map, such that Mpq={0if cell (p,q) is dead1if cell (p,q) is aliveM_{pq} = \begin{cases} 0 & \text{if cell }(p, q)\text{ is dead} \\ 1 & \text{if cell }(p, q)\text{ is alive} \end{cases}, is a cyclic key to index a tones array. The tones array is chosen based on the key's even parity: either it is the minor or major subset TT from the circle of fifths: T[(SijmodT)+T]modTT_{[(||S_{ij}|| \mod ||T||) + ||T||] \mod ||T||}. Such tones for all the sound grid cells are then played simultaneously on the polyphonic synthesizer provided by Tone.js, uniformly distributed over the time foreseen for a single audio loop (a value of 23\frac{2}{3}s), with an additional oscillator to modulate signal frequency and an AM envelope. Additionally, a slight portamento is applied afterwards so as to make the transition between synthesized sounds smoother. The computed notes are visualized in the table above, marked with chromestetic colors associated with them.
Consecutive tones are mixed one-after-another using a polyphonic mixer, and are synthesized using either of voice synthesizers: FMSynth or DuoSynth, which is configurable and can be picked in the floating settings menu.