Forum Discussion

Patrick2788's avatar
Patrick2788
Silver Contributor
Jan 25, 2024

Utilizing Excel's turing capabilities to create Conway's 'Game of Life'

The Background

It's been said with Lambda (and LET and a wealth of functions in 365) Excel has become 'turing-complete'.

To quote the article linked below:
"You can now, in principle, write any computation in the Excel formula language."

https://www.microsoft.com/en-us/research/blog/lambda-the-ultimatae-excel-worksheet-function/

 

The Challenge
I thought it would be fun to create Conway's 'Game of Life' in Excel 365 to see how far I could push things.

Conway's Game of Life - Wikipedia

 

The rules are simple:

A 'cell' has up to 8 adjacent cells (less if the cell is on the edge of the board).  A 'neighbor' is a cell with a 1 while a 'dead' cell is empty.

 

An 18x18 board

Multiple iterations

Bigger boards!

more (it's relaxing to create new shapes and designs)

 

The Approach

My first thought was to use MAKEARRAY because I could use 'r' and 'c' coordinates and there would be no stacking.  I devised a recursive function that worked for 1 iteration but failed on subsequent iterations because the use of TAKE/DROP was slowly shrinking the board!

 

The revised approach is  essentialy a recursive MAP that uses 3 arrays: the input matrix, the 'r' array (row numbers) and the 'c' array (column numbers).  It's my way of using r/c without using MAKEARRAY.

 

For Discussion

I welcome any improvements to the existing function and any different approaches someone may have to creating Conway's Game of Life.

 

Conway Lambda follows:

 

 

Conway
=LAMBDA(matrix, iterations,
    IF(
        iterations = 0,
        matrix,
        Conway(
            LET(
                height, ROWS(matrix),
                width, COLUMNS(matrix),
                r_arr, SEQUENCE(height) * SEQUENCE(, width, 1, 0),
                c_arr, SEQUENCE(height, , 1, 0) * SEQUENCE(, width),
                CheckNeighbors, LAMBDA(lattice, r, c,
                    LET(
                        RCx, LAMBDA(row, col,
                            IFERROR(CHOOSECOLS(CHOOSEROWS(matrix, row), col), 0)
                        ),
                        N, RCx(r - 1, c),
                        NE, RCx(r - 1, c + 1),
                        E, RCx(r, c + 1),
                        SE, RCx(r + 1, c + 1),
                        S, RCx(r + 1, c),
                        SW, RCx(r + 1, c - 1),
                        W, RCx(r, c - 1),
                        NW, RCx(r - 1, c - 1),
                        compass, VSTACK(N, NE, E, SE, S, SW, W, NW),
                        neighbors, SUM(compass),
                        IF(
                            AND(lattice = 0, neighbors = 3),
                            1,
                            IF(
                                AND(lattice = 1, OR(neighbors = 2, neighbors = 3)),
                                1,
                                0
                            )
                        )
                    )
                ),
                MAP(matrix, r_arr, c_arr, CheckNeighbors)
            ),
            iterations - 1
        )
    )
)

27 Replies

  • djclements's avatar
    djclements
    Bronze Contributor

    INDEX can be used with an array of row and column numbers to transform the board into a table of records for each cell and its neighbors. Since INDEX does not trigger an error when 0 is provided as the row and/or column number, a logical test is needed to handle out-of-scope references for the outer edges of the board. From there, BYROW-SUM is used to get the total number of live neighbors for each cell, and WRAPROWS returns the new board back to its original dimensions.

    =IF(
       NOT(IterationsA),
       board,
       LET(
          h, ROWS(board),
          w, COLUMNS(board),
          i, TOCOL(IFNA(SEQUENCE(h),board))+{0,0,0,1,1,1,-1,-1,-1},
          j, TOCOL(IFNA(SEQUENCE(,w),board))+{0,1,-1,0,1,-1,0,1,-1},
          t, i*j*(i<=h)*(j<=w),
          REDUCE(
             board,
             SEQUENCE(IterationsA),
             LAMBDA(arr,num,
                LET(
                   a, IF(t,INDEX(arr,i,j),0),
                   n, BYROW(DROP(a,,1),SUM),
                   WRAPROWS(TAKE(a,,1)*(n=2)+(n=3),w)
                )
             )
          )
       )
    )

    Alternatively, you could EXPAND the board to add an outer border of 0's, then DROP the appropriate number of rows and/or columns to shift the entire board in each of the 8 directions, to identify all neighbors at once.

    =IF(
       NOT(IterationsB),
       BigBoard,
       LET(
          h, ROWS(BigBoard)+1,
          w, COLUMNS(BigBoard)+1,
          i, EXPAND(0,h+1,,0),
          j, EXPAND(0,,w,0),
          REDUCE(
             BigBoard,
             SEQUENCE(IterationsB),
             LAMBDA(arr,num,
                LET(
                   a, HSTACK(i,VSTACK(j,EXPAND(arr,h,w,0))),
                   ↑, DROP(DROP(a,,1),-2,-1),
                   ↓, DROP(DROP(a,2,1),,-1),
                   ←, DROP(DROP(a,1),-1,-2),
                   →, DROP(DROP(a,1,2),-1),
                   ↖, DROP(a,-2,-2),
                   ↙, DROP(a,2,-2),
                   ↗, DROP(a,-2,2),
                   ↘, DROP(a,2,2),
                   n, ↑ + ↓ + ← + → + ↖ + ↙ + ↗ + ↘,
                   arr*(n=2)+(n=3)
                )
             )
          )
       )
    )

    This can also be done systematically with MAP and REDUCE, which is demonstrated in the attached workbook. I've also included two boards that loop/repeat, so you can just click and hold the spinner button to watch it go!

    Cheers!

    • djclements's avatar
      djclements
      Bronze Contributor

      Matrix Flow Accumulation adaptation using the same basic concept to transform the matrix into a table of records, then lambda recursion to generate a hierarchy table summarized with GROUPBY:

      =LET(
         rng, BigFlowMatrix,
         h, ROWS(rng),
         w, COLUMNS(rng),
         n, SEQUENCE(h,w),
         i, TOCOL(IF(n,SEQUENCE(h)))+{0,0,-1,-1,-1,0,1,1,1},
         j, TOCOL(IF(n,SEQUENCE(,w)))+{0,-1,-1,0,1,1,1,0,-1},
         t, i*j*(i<=h)*(j<=w),
         val, IF(t,INDEX(rng,i,j),0),
         num, IF(t,INDEX(n,i,j),0),
         tst, DROP(val,,1)={1,2,4,8,16,32,64,128},
         pId, TOCOL(IFS(tst,TAKE(num,,1)),2),
         cId, TOCOL(IFS(tst,DROP(num,,1)),2),
         fnλ, LAMBDA(me,arr,[acc],
            LET(
               acc, IF(ISOMITTED(acc),arr,acc),
               inc, ISNUMBER(XMATCH(DROP(arr,,1),pId,,2)),
               IF(
                  OR(inc),
                  LET(
                     a, TRANSPOSE(FILTER(arr,inc)),
                     b, DROP(a,1)=pId,
                     c, HSTACK(TOCOL(IFS(b,TAKE(a,1)),2),TOCOL(IFS(b,cId),2)),
                     me(me,c,VSTACK(acc,c))
                  ),
                  GROUPBY(TAKE(acc,,1),DROP(acc,,1),ROWS,0,0)
               )
            )
         ),
         tbl, fnλ(fnλ,HSTACK(pId,cId)),
         XLOOKUP(n,TAKE(tbl,,1),DROP(tbl,,1),0,,2)
      )

      😊

      • Patrick2788's avatar
        Patrick2788
        Silver Contributor

        Excel has come a long way in the last 6-7 years giving us multiple ways to solve problems.  A 4th solution would make this a solution party!

        Our approaches are similar but differ a bit with the recursive function and the use of GROUPBY to do the aggregations.  I like GROUPBY because it lessens the burden on REDUCE to handle such calculations.

         

        There is another matrix that precedes the directional matrix provided in the sample.  This matrix contains elevation data which is checked with another algorithm.  The algorithm is summarized here:

        How Derive Continuous Flow works—ArcGIS Online | Documentation

        It sounds do-able but certainly a bit of a time sink!

  • Patrick2788 

    You have prompted me to have another go.  I have made some minor changes to your solution such as using REDUCE rather than recursion and a DROP/TAKE combination to isolate neighbourhoods.

    The fun step was then to switch REDUCE to SCAN and produce the result as an array of thunks.  That allows the user to iterate the board in place or display the entire sequence of results.

    Worksheet formula
    = LET(
        result, SCAN(THUNK(board), SEQUENCE(generations), Stepλ),
        EVALTHUNKARRλ(IF(ShowAll, result, TAKE(result,-1)))
      )
    
    
    //A simulation of Conway's Game of Life
    Stepλ 
    =LAMBDA(gridϑ, k,
        LET(
            grid, gridϑ(),
            life, MAKEARRAY(
                ROWS(grid),
                COLUMNS(grid),
                LAMBDA(r, c,
                    LET(
                        border?, OR(r=1, c=1, r=ROWS(grid), c=COLUMNS(grid)),
                        // Identify neighbourhood and cell state
                        neighbourhood, TAKE(DROP(grid, r - 2, c - 2), 3, 3),
                        alive, INDEX(neighbourhood, 2, 2),
                        //total the neighbours
                        count, SUM(neighbourhood) - alive,
                        //cell has 3 neighbors or is alive and has 2 neighbors - keep
                        IF(border?, "x",  alive * (count = 2) + (count = 3))
                    )
                )
            ),
    
        THUNK(life))
    );

     

    • Patrick2788's avatar
      Patrick2788
      Silver Contributor

      This is a clever approach to a situation that on the surface appears to only be solvable with recursion. As I understand it:

      The board is thunked immediately and provided as the initial value (It's a good way to "fit a matrix through a scalar-sized keyhole"). I like how SCAN does its work on {1;2;3...} instead of having to accept the entire board. 'k' appears to arbitrary in that it fulfills a parameter but SCAN is only concerned with the accumulated matrix. Due to the limitations of SCAN, the result must be thunked (because it can't yet be properly evaluated) and unpacked with EVALTHUNKARRλ.

      I may try this approach with of few of the Wolfram rules that use a starting configuration of {0,1,0} that can be solved with recursion and a few bit functions. It's probably not needed but it's the experimentation that's fun!

      • PeterBartholomew1's avatar
        PeterBartholomew1
        Silver Contributor

        Patrick2788 

        Nice description!

        The array of thunks allows a reasonable level of customisation of the layout as well.  For example

        = LET(
            resultϑ, SCAN(THUNK(board), SEQUENCE(generations), Stepλ),
            EVALTHUNKARRλ(IF(ShowAll, WRAPROWS(resultϑ,5), TAKE(resultϑ,-1)))
          )

        will arrange the display to have 5 boards across each row.  I also changed the spinner to increment in fives.

  • Patrick2788's avatar
    Patrick2788
    Silver Contributor

    This updated approach for 2025 uses a kernel and MAP to do the heavy lifting.  I tend to err on the side of more parameters and readability.

     

    //Kernel coordinates:
    x = {-1;-1;-1;0;1;1;1;0};
    y = {-1;0;1;1;1;0;-1;-1};
    
    
    //A simulation of Conway's Game of Life
    Conwayλ =
    
    LAMBDA(config,n,
        LET(
        //determine dimensions of the board
            h,ROWS(config),
            w,COLUMNS(config),
        //create matrices of coordinates
            i,SEQUENCE(h)*SEQUENCE(,w,1,0),
            j,TRANSPOSE(i),
        //determine if cell is to be kept or discarded
            generate,MAP(i,j,LAMBDA(i_,j_,
                LET(
                    each_cell,INDEX(config,i_,j_),
                //generate coordinates for each cell
                //discard certain coordinates if cell is on perimeter of board
                    kernel,(i_+ x>0)*(j_+y>0)*(i_+ x<=h)*(j_+y<=w),
                    r,FILTER(i_ + x,kernel),
                    c,FILTER(j_+ y, kernel),
                //total the neigbors
                    neighbors,SUM(INDEX(config,r,c)),
                //cell is 0 but has exactly 3 neighbors - keep
                    revive,(each_cell=0)*(neighbors=3),
                //cell is alive and has 2 or 3 neighbors - keep
                    keep,(each_cell=1)*(neighbors=2)+(neighbors=3),
                    IF(revive,1,IF(keep,1,0))))),
            IF(n=0,config,Conwayλ(generate,n-1))))

     

  • Patrick2788 

    INDEX was far from my first choice too, but I eventually settled for testing row and column numbers for 0 and n+1.  I also tested Riny_van_Eekelen 's start and at iteration 4 it returned the same shape but displaced. 

     

    Your original pattern became static at iteration 50.  An advantage of recursion is that one could terminate the calculation there whereas REDUCE bashed on for the full 100 that I had set.

     

     

  • Riny_van_Eekelen's avatar
    Riny_van_Eekelen
    Platinum Contributor

    Patrick2788 Interesting. I remember doing this in machine code on my Comodore64 in the late 70's. Especially the famous 'Slider' figure that just continues to in a sequence of repeating patterns. It can be seen in the wiki link you provided.

    The start is like this:

    The 2nd generation becomes:

    And the third should be like below though the red cells do not become alive in your formula:

    Stepping back to the previous state and highlighting these two red cells, and it's clear that they should become alive in the next generation as the both have three neighbors that are alive at the time. Or have I misunderstood?

     

    • Patrick2788's avatar
      Patrick2788
      Silver Contributor
      Thank you for taking a look. I've determined the 'SW' check is missing. The formula sees the cell as 0 with the neighbors for the cell at 2 so it's not making it live. If I'm able to have some uninterrupted time at work I may be able to fix it.
      • Patrick2788's avatar
        Patrick2788
        Silver Contributor

        Riny_van_Eekelen 

        I've updated the Conway Lambda in the first post and replaced the workbook.  The issue was IFERROR was resolving to "" when it should have been 0.

         

        PeterBartholomew1 

        In re:

         

        Stepλ(board)
        =LAMBDA(r, c,
            LET(
                n, 18,
                state, INDEX(board, r, c),
                onboard, SIGN(MOD(r + δr, n + 1)) * SIGN(MOD(c + δc, n + 1)),
                count, SUM(IF(onboard, INDEX(board, r + δr, c + δc), 0)) - state,
                IF(state, SUM(SIGN(count = {2, 3})), SIGN(count = 3))
            )
        )

         

        This a really clever use of the functions available!  I like how you've really simplified the logic by approaching it this way.  My use of IF-AND-OR is slowing calculations a bit.

         

        Below is my MAKEARRAY version.  I've compared the results up to 20 iterations against my original and Peter's solution and they're identical.

         

        'Conway with MAKEARRAY
        =LAMBDA(matrix,iterations,IF(
            iterations = 0,
            matrix,
            Conway(
                LET(
                    height, ROWS(matrix),
                    width, COLUMNS(matrix),
                    CheckNeighbors, LAMBDA(r, c,
                        LET(
                            grid_val, INDEX(matrix, r, c),
                            ext_rows, SEQUENCE(3, , r - 1),
                            ext_cols, SEQUENCE(, 3, c - 1),
                            Square3x3, INDEX(matrix, ext_rows, ext_cols),
                            neighbors, IF(
                                OR(r = 1, c = 1, r = height, c = width),
                                0,
                                SUM(Square3x3) - grid_val
                            ),
                            IF(
                                AND(grid_val = 0, neighbors = 3),
                                1,
                                IF(AND(grid_val = 1, OR(neighbors = 2, neighbors = 3)), 1, 0)
                            )
                        )
                    ),
                    MAKEARRAY(height, width, CheckNeighbors)
                ),
                iterations - 1
            )
        ))

         

        My goal was to simplify things - mainly the directional checks.  For this version I'm supplying the Lambda with a 'padded' matrix:

        With MAKEARRAY I then 'offset' from the 'grid-val' with INDEX and the rest becomes simpler.

  • Patrick2788 

    I too had a one-step success, followed by failure.  I started by just exploring a single step of the process and came up with

    Stepλ
    "Calculates outturn for a single cell"
    = LET(
        count, SUM(OFFSET(cell, -1, -1, 3, 3)) - cell,
        IF(cell, SUM(SIGN(count = {2, 3})), SIGN(count = 3))
      )

    For the entire board

    = MAP(start, Stepλ)

    worked fine but

    = REDUCE(start, SEQUENCE(2), LAMBDA(board,k, MAP(board, Stepλ)))

    failed because OFFSET is a range operator and, at step 2, I was offering it an array.  Back to the drawing board!  I would probably stick with REDUCE but it is back to the start with OFFSET.

    At the moment I see myself as going with MAKEARRAY in order to generate indices and 

    = SUM(INDEX(start,r+δr,c+δc)) - INDEX(start,r,c)
    
    δr = {1;0;-1}
    δc = {1,0,-1}

    Still some edge conditions to sort, though.

    • Patrick2788's avatar
      Patrick2788
      Silver Contributor

      The big problem I encounterd with INDEX (and why you see my odd CHOOSECOLS-CHOOSEROWS concoction) is what happens when 'r' and/or 'c' is 0. I'd rather have INDEX fail in such a scenario so I went with the two nested functions. TAKE/DROP seem to make sense but were slowly eating the board on each iteration.

Resources