Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created April 2, 2023 14:19
Show Gist options
  • Save bennadel/f5f27b9f7b6ee374b5d8eb32738c8f58 to your computer and use it in GitHub Desktop.
Save bennadel/f5f27b9f7b6ee374b5d8eb32738c8f58 to your computer and use it in GitHub Desktop.
Conway's Game Of Life In Hotwire And ColdFusion
<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>
<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>
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 );
}
}
<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>
// 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 );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment