Conway's Game Of Life In Hotwire And ColdFusion
Conway's Game of Life is a simple, single-player game in which you define the initial state of the game and then the rest of the game is autonomously driven by a small set of rules. I've never actually played this game before; so, I thought it might be fun to explore the Game of Life using Hotwire and ColdFusion.
View this code in my ColdFusion + Hotwire Demos project on GitHub.
The Game of Life consists of a grid of cells. Each cell is considered to be either alive or dead. As the game play moves forward, the state transformation of each cell is determine solely by the existence of cells around it. As such, once the initial state of the game is defined, no other input is needed.
There are essentially two rules that determine what happens to any given cell during a round of game play:
Any living cell that has exactly two or three living neighbors survives and moves onto the next round.
Any dead cell that has exactly three living neighbors springs into life.
Any living cell that doesn't meet the criteria above will die. Any dead cell that doesn't meet the criteria above will remain dead. A "neighbor" is defined as one of the 8 cells (vertically, horizontally, and diagonally) that surround a given cell.
The "board" for the Game of Life is infinite. And, it is "sparse". Meaning, there are many more (an infinite number of) dead cells than there are living cells. As such, I'm not going to model the game state as a 2-dimensional matrix. Instead, I'm going to model the living cells only. Each living cell will be kept in an index whose key is a composite of the cell's {x,y}
coordinate.
Then, as the game play moves forward, all I have to do is iterate over this one index linearly and compute the context in which each cell exists. Any cell that is not tracked in the index is implicitly dead. Since one of the rules of the game pertains to dead cells that have living neighbors, I can keep track of the relevant dead cells as I iterate over the living cells (since we only care about dead cells that have living neighbors).
I've encapsulated all of the Game of Life state and state transformation logic in a single ColdFusion component - GameOfLife.cfc
. It has only two public methods:
tick()
- this moves the game play forward by one life-cycle.getState()
- this returns a copy of the current game state that we can use to render the board.
Each state snapshot consists of the bounds of the board and the aforementioned index of composite x,y
keys.
component
output = false
hint = "I provide game play methods for Conway's Game of Life."
{
/**
* I initialize the Game of Life with the given cell keys. Each key is a composite of
* the "x,y" coordinate of a living cell within the infinite canvas of biodiversity.
*/
public void function init(
numeric minX = 0,
numeric maxX = 15,
numeric minY = 0,
numeric maxY = 15,
array cellKeys = []
) {
// As the board grows and shrinks, we never want the visual rendering to shrink
// below a certain boundary.
variables.boardMinX = arguments.minX;
variables.boardMaxX = arguments.maxX;
variables.boardMinY = arguments.minY;
variables.boardMaxY = arguments.maxY;
variables.currentState = stateNew( cellKeys );
// The rules of the game are based on the number of living / dead cells around a
// given cell. To make those calculations easier, this collection represents the
// {x,y} delta for each neighboring cell.
variables.neighborOffsets = [
{ x: -1 , y: -1 },
{ x: 0 , y: -1 },
{ x: 1 , y: -1 },
{ x: -1 , y: 0 },
{ x: 1 , y: 0 },
{ x: -1 , y: 1 },
{ x: 0 , y: 1 },
{ x: 1 , y: 1 }
];
}
// ---
// PUBLIC METHODS.
// ---
/**
* I get the current state of the game board.
*/
public struct function getState() {
return({
minX: currentState.minX,
maxX: currentState.maxX,
minY: currentState.minY,
maxY: currentState.maxY,
cellIndex: currentState.cellIndex.copy()
});
}
/**
* I move the state of the board ahead by one life-tick.
*/
public void function tick() {
// We don't want to change the state of the current board while we are examining
// it as doing so may corrupt further checks within the algorithm. As such, we're
// going to build-up a new state and then swap it in for the current state.
var nextState = stateNew();
// The game requires us to look at both the living cells and the dead cells (that
// are around the living cells). Since dead cells aren't explicitly tracked in the
// state of the board itself - there are an infinite number of dead cells - we
// need to track the dead cells that are around the living cells that we inspect.
var deadCellIndex = [:];
for ( var key in currentState.cellIndex ) {
var cell = stateGetCell( currentState, key );
// Living cells remain alive if they have 2 or 3 living neighbors.
if (
( cell.livingNeighbors.len() == 2 ) ||
( cell.livingNeighbors.len() == 3 )
) {
stateActivateCell( nextState, key );
}
// Track the dead neighbors around the living cell so that we can explore them
// once we are done processing the living cells.
for ( var deadKey in cell.deadNeighbors ) {
deadCellIndex[ deadKey ] = true;
}
}
// Now that we've located any relevant dead cells (neighboring living cells), we
// can see if any of them need to spring to life.
for ( var key in deadCellIndex ) {
var cell = stateGetCell( currentState, key );
// If the cell is dead, then it becomes alive if it has 3 living neighbors.
if ( cell.livingNeighbors.len() == 3 ) {
stateActivateCell( nextState, key );
}
}
// Swap in the new game state.
currentState = nextState;
}
// ---
// PRIVATE METHODS.
// ---
/**
* I activate the cell for the given key (marking it as alive).
*/
private void function stateActivateCell(
required struct state,
required string key
) {
var coordinates = key.listToArray();
var x = fix( coordinates[ 1 ] );
var y = fix( coordinates[ 2 ] );
state.cellIndex[ key ] = true;
state.minX = min( state.minX, x );
state.maxX = max( state.maxX, x );
state.minY = min( state.minY, y );
state.maxY = max( state.maxY, y );
}
/**
* I get the cell (and some contextual info) for the given key.
*/
private struct function stateGetCell(
required struct state,
required string key
) {
var coordinates = key.listToArray();
var x = fix( coordinates[ 1 ] );
var y = fix( coordinates[ 2 ] );
var cell = {
x: x,
y: y,
livingNeighbors: [],
deadNeighbors: []
};
for ( var offset in neighborOffsets ) {
var neighborX = ( cell.x + offset.x );
var neighborY = ( cell.y + offset.y );
var neighborKey = "#neighborX#,#neighborY#";
if ( state.cellIndex.keyExists( neighborKey ) ) {
cell.livingNeighbors.append( neighborKey );
} else {
cell.deadNeighbors.append( neighborKey );
}
}
return( cell );
}
/**
* I create a new state data model. If initial keys are provided, they will be used to
* activate cells in the new state.
*/
private struct function stateNew( array cellKeys = [] ) {
// Instead of keeping a two-dimensional array of the entire board, we're just
// going to keep an index of the living cells based on a composite key of the
//"x,y" magnitudes. The VALUE of the entry doesn't matter.
var state = {
cellIndex: [:],
minX: boardMinX,
maxX: boardMaxX,
minY: boardMinY,
maxY: boardMaxY
};
for ( var key in cellKeys ) {
stateActivateCell( state, key );
}
return( state );
}
}
While I'm modeling the Game of Life internally as a linear index of keys, from the user's perspective it's still a two-dimensional grid. And, the user has to define the initial state of the grid before the game play starts. As such, I decided to implement the Game of Life as a grid of checkboxes. When a checkbox is checked, it denotes a living cell. Any unchecked checkbox denotes a dead cell.
The rendering of the Game of Life board is going to have to be changed on each tick of the game play. As such, I've created reusable ColdFusion template that takes a variable state
(returned by the .getState()
method above) and renders the checkboxes. This is _board.cfm
:
<cfoutput>
<!---
In our Game of Life board, each living cell is being represented by a CHECKBOX
whose value denotes the "x,y" coordinates of the cell within the infinite canvas.
On each subsequent page load, the checkbox will be CHECKED if its composite "x,y"
key still exists in the index of living cells.
--
NOTE: We are including an ID on the board so that we can replace its rendering via
a Turbo Stream action.
--->
<div id="board" class="board">
<cfloop index="y" from="#state.minY#" to="#state.maxY#">
<div>
<cfloop index="x" from="#state.minX#" to="#state.maxX#">
<label>
<input
type="checkbox"
name="cells[]"
value="#x#,#y#"
<cfif state.cellIndex.keyExists( "#x#,#y#" )>checked</cfif>
/>
</label>
</cfloop>
</div>
</cfloop>
</div>
</cfoutput>
As you can see, this is a fairly standard nested-loop approach to rendering a grid: I loop over the rows in the outer loop and then the columns in the inner loop. The value
of each checkbox is the key that I will use to index living cells. This means that when the user submits the form for the initial game design, my form.cells
attribute contains an array of all living cells, which an be used to initialize the GameOfLife.cfc
instance.
When a form is submitted via Hotwire Turbo Drive, I can't simply re-render the board - doing so will result in a Turbo error. Instead, a form submission needs one of the following:
A
location
header redirecting the user to another page.A non-
2xx
status code to render an error.A Turbo Stream response used to update the existing Document Object Model (DOM) tree.
Since our game board is already neatly extracted into its own ColdFusion template (see above), we can generate a small Turbo Stream response to our form submission:
<cfscript>
content
type = "text/vnd.turbo-stream.html; charset=utf-8"
;
</cfscript>
<turbo-stream action="replace" target="board">
<template>
<cfinclude template="_board.cfm" />
</template>
</turbo-stream>
Our Turbo Stream response action is going to replace the element with id="board"
; and, it's going to use the content of the <template>
which is nothing more than a re-rendering the _board.cfm
template. This allows us to show the next state of the game once the user submits the initial board configuration.
Since each state of the game is based completely on the previous state, we can actually use our form post mechanism to continue driving the Game of Life forward. Each "tick" of the game loop (so to speak) can be dictated by a form submission of the previous state of checkboxes.
To wire this all together, I'm going to use a Stimulus controller that waits for the form submission to be completed (event: turbo:submit-end
) and then uses a setTimeout()
to submit the form back to the server. This way, we'll automatically keep the game moving forward.
Here's my main ColdFusion page - it works both with or without Stimulus by including an inline Script tag that only fires if Stimulus has not taken control:
<cfscript>
param name="form.cells" type="array" default=[];
param name="form.autoUpdate" type="boolean" default=false;
param name="form.submitted" type="boolean" default=false;
// On every page request, we're going to initialize the Game of Life with the
// currently submitted cells.
game = new lib.GameOfLife(
minX = -10,
maxX = 10,
minY = -10,
maxY = 10,
cellKeys = form.cells
);
// If this is a form-submission, move the evolution of the game forward 1-tick.
if ( form.submitted ) {
game.tick();
}
state = game.getState();
// If the form is set to auto-update, but there are no living cells left on the board,
// disable the auto-update. Subsequent requests won't change the board in any way.
if ( form.autoUpdate && state.cellIndex.isEmpty() ) {
form.autoUpdate = false;
}
// If this request is a form submission via Turbo Drive , then we cannot simply re-
// render the form - Turbo Drive requires either a redirect, an error, or a Turbo
// Stream. As such, we're going to have to REPLACE the board with a stream action
// rather than update the whole page.
if ( request.isPost && request.turbo.isStream ) {
include "./_board.stream.cfm";
exit;
}
</cfscript>
<cfmodule template="./tags/page.cfm">
<cfoutput>
<form
method="post"
action="index.htm"
data-controller="game"
data-action="turbo:submit-end->game##checkForAutoUpdate"
data-game-interval-param="500"
data-game-target="form">
<input type="hidden" name="submitted" value="true" />
<div class="controls">
<button type="submit">
Increment Game Play
</button>
<label for="auto-update">
<input
id="auto-update"
type="checkbox"
name="autoUpdate"
value="true"
data-game-target="autoUpdate"
<cfif form.autoUpdate>checked</cfif>
/>
Auto update
</label>
<a href="index.htm">
Reset
</a>
</div>
<cfinclude template="./_board.cfm" />
</form>
<!---
Graceful degradation: If Hotwire Turbo Drive is not managing the game play,
then we want to fall-back to using vanilla JavaScript to automatically submit
the board. If on any subsequent page load, the Turbo Drive script kicks-in,
it will hook into the `requestSubmit()` life-cycle, take over, and this script
will no longer be relevant.
--->
<cfif form.autoUpdate>
<script type="text/javascript">
var form = document.querySelector( "form" );
setTimeout(
() => {
form.requestSubmit();
},
form.dataset.gameIntervalParam
);
</script>
</cfif>
</cfoutput>
</cfmodule>
Note that the <form>
tag has the attribute:
data-action="turbo:submit-end->game##checkForAutoUpdate"
When Turbo Drive completes the form submission via the fetch()
API, we're going to invoke our Stimulus controller method, checkForAutoUpdate()
. All this does it check to see if the "Auto Submit" checkbox is enabled; and, if so, re-submits the form shortly:
// Import core modules.
import { Application } from "@hotwired/stimulus";
import { Controller } from "@hotwired/stimulus";
import * as Turbo from "@hotwired/turbo";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export class GameController extends Controller {
static targets = [ "form", "autoUpdate" ];
// ---
// PUBLIC METHODS.
// ---
/**
* When the board tick has been processed, I check to see if the user wants the next
* board tick to be fired automatically.
*/
checkForAutoUpdate( event ) {
if (
! this.autoUpdateTarget.checked ||
! event.params.interval
) {
return;
}
setTimeout(
() => {
// Before we attempt to tick forward the game play, let's make sure that
// we actually have any living cells left on the board. If the board is
// empty, there's no chance that anything will suddenly burst into life.
if ( this.hasLivingCells() ) {
this.formTarget.requestSubmit();
} else {
this.autoUpdateTarget.checked = false;
}
},
event.params.interval
);
}
// ---
// PRIVATE METHODS.
// ---
/**
* I determine if there are any living cells currently on the board.
*/
hasLivingCells() {
return( !! this.formTarget.querySelector( ".board input[checked]" ) );
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
window.Stimulus = Application.start();
// When not using the Ruby On Rails asset pipeline / build system, Stimulus doesn't know
// how to map controller classes to data-controller attributes. As such, we have to
// explicitly register the Controllers on Stimulus startup.
Stimulus.register( "game", GameController );
Now, pulling this all together, if we run the ColdFusion app and start with a common Game of Life pattern, we can see the game play run automatically:
Notice that the page timestamp at the bottom of the GIF remains the same. This is because, after the initial page load, all of the subsequent state change is being requested via Turbo Drive and then rendered via Turbo Streams.
The more I play with Hotwire, the more I am realizing how critical Turbo Streams are to making things work. At first, as I was starting to find my way in the Hotwire world, I resisted using Streams as much as I could. But, the moment you introduce form submissions, it seems like Turbo Streams really start to drive a lot of the functionality.
Want to use code from this post? Check out the license.
Reader Comments
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →