Efficiently drawing a grid using live view

I am writing a snake game as my first LV non DB app.

I have a 10 by 10 css grid (will obviously be bigger in the final game) and am filling the cells with either ‘.’ or ‘x’ (I am just getting started). My problem is that the whole grid is sent every repaint,

This is my code


   ~H"""
    <div>
      <h1>Snake Game</h1>
    </div>

    <div>{IO.inspect(@snake.direction)}</div>

    <div class="snake_grid" id="game_surface" phx-window-keydown="key_pressed">
      {raw(elem(draw_grid(assigns), 0))}
    </div>
    """
  end

and

  def draw_grid(assigns) do
    height = 10
    width = 10

    Enum.reduce(((height - 1) * width - 1)..0, {[], 0}, fn idx, acc ->
      row = div(idx, width)
      col = rem(idx, width)
      {cell, current} = draw_cell(assigns, elem(acc, 1), row, col)

      case cell do
        :snake ->
          {["<div>x</div>" | elem(acc, 0)], current}

        :empty ->
          {["<div>.</div>" | elem(acc, 0)], current}
      end
    end)
  end

When the snake moves typically only two cells change, all the others do not need updating. But here I end up resending everything in the grid

I get the feeling that I need a separate assigns entry for each cell in order for the update to be efficiently diffed. So I would have

    <div class="snake_grid" id="game_surface" phx-window-keydown="key_pressed">
       <div>@cell_0_0</div>
       <div>@cell_0_1</div>
       <div>@cell_0_2</div>
        ....

    </div>

This seems like overkill for a , say, 200* 200 grid

1 Like

Could you please share also draw_cell/4 function? What we know is that you reduce over some acc, but it’s not really clear what current value is. From the initial data it looks like integer, but in practice it could be anything. The draw_cell naming is unclear as you only return an atom and the current integer.

For example if the current is index (i.e. 0, 1, 2 etc.) then you may consider using Enum.with_index/1 and then write a cell function component with a :for HEEx attribute instead of Enum.reduce/3 call. This way while you still would generate all cells, LiveView would only send only the changed ones.

here is draw_call

def draw_cell(assigns, current, row, col) do
require Logger
# Logger.debug(IO.inspect([row, col, current, Enum.at(assigns.snake.cells, current)]))

if Enum.at(assigns.snake.cells, current) == {row, col} do
  {:snake, current + 1}
else
  {:empty, current}
end

end

I know I have tied myself in knots a bit trying to step through the snake.cells list, I realize that this is unneccassry becuase Enum.at doesnt directly index. I Could simplify this code but I dont think that would change anything regarding the efficent diffing

here it is recoded without such complicated (useless) indexing

~H"""
<div>
  <h1>Snake Game</h1>
</div>

<div>{IO.inspect(@snake.direction)}</div>

<div class="snake_grid" id="game_surface" phx-window-keydown="key_pressed">
  {raw(draw_grid(assigns))}
</div>
"""

end

def draw_grid(assigns) do
height = 10
width = 10

Enum.reduce(((height - 1) * width - 1)..0, [], fn idx, acc ->
  row = div(idx, height)
  col = rem(idx, width)
  cell = draw_cell(assigns, row, col)

  case cell do
    :snake ->
      ["<div>x</div>" | acc]

    :empty ->
      ["<div>.</div>" | acc]
  end
end)

end

def draw_cell(assigns, row, col) do
require Logger
# Logger.debug(IO.inspect([row, col, current, Enum.at(assigns.snake.cells, current)]))

if Enum.any?(assigns.snake.cells, fn cell -> cell == {row, col} end) do
  :snake
else
  :empty
end

end

How about something like:

<div class="snake_grid" id="game_surface" phx-window-keydown="key_pressed">
  <div :for={idx <- 0..grid_size(@width, @height)}>
    {draw_cell(idx, @width, @height, @snake.cells)}
  </div>
</div>

Here are the helper functions:

def grid_size(width, height) do
  (height - 1) * width - 1
end

def draw_cell(ids, width, height, snake_cells) do
  if {div(idx, width), rem(idx, width)} in snake_cells do
    "x"
  else
    "."
  end
end

LiveView’s change tracking doesn’t work when iterating over lists. You can use LiveView streams (not to be confused with Stream module) or LiveComponents to compensate.

You might want to look at this reply to another thread.

1 Like

Ty for the pointer. I now get only changes sent , using LiveComponents.

1 Like

Try using phx-update="replace" on grid cells to minimize re-rendering. LiveComponents for each cell could also help update only the changed ones efficiently.

phx-update="replace" is the default. DOM patching docs

OK, i now use live view but although i get the diff sent now (thank you for the pointer) a large amount of meta data is still sent along side the diff - there is an entry per cell.

code


  def render(assigns) do
    require Logger
    # Logger.debug(IO.inspect(assigns))
    # cell_idx = 0
    # cur_cell = Enum.at(assigns.snake.cells, cell_idx)

    ~H"""
    <div>
      <h1>Snake Game</h1>
    </div>

    <div>{IO.inspect(@snake.direction)}</div>

    <div class="snake_grid" id="game_surface" phx-window-keydown="key_pressed">
      <.live_component
        :for={idx <- 0..(grid_size(10, 10) - 1)}
        module={SnakeWeb.Cell}
        id={"cell_#{idx}"}
        content={draw_cell(idx, 10, 10, @snake.cells)}
      />
    </div>
    """
  end

  def grid_size(width, height) do
    height * width
  end

  def draw_cell(idx, width, height, snake_cells) do
    if {div(idx, height), rem(idx, width)} in snake_cells do
      "x"
    else
      "."
    end
  end

this is what I get in the browser console

hx-GCeYIojSHniP1Aam update: - Object3: 1: d: Array(100)0: [1]1: [2]2: [3]3: [4]4: [5]5: [6]6: [7]7: [8]8: [9]9: [10]10: [11]11: [12]12: [13]13: [14]14: [15]15: [16]16: [17]17: [18]18: [19]19: [20]20: [21]21: [22]22: [23]23: [24]24: [25]25: [26]26: [27]27: [28]28: [29]29: [30]30: [31]31: [32]32: [33]33: [34]34: [35]35: [36]36: [37]37: [38]38: [39]39: [40]40: [41]41: [42]42: [43]43: [44]44: [45]45: [46]46: [47]47: [48]48: [49]49: [50]50: [51]51: [52]52: [53]53: [54]54: [55]55: [56]56: [57]57: [58]58: [59]59: [60]60: [61]61: [62]62: [63]63: [64]64: [65]65: [66]66: [67]67: [68]68: [69]69: [70]70: [71]71: [72]72: [73]73: [74]74: [75]75: [76]76: [77]77: [78]78: [79]79: [80]80: [81]81: [82]82: [83]83: [84]84: [85]85: [86]86: [87]87: [88]88: [89]89: [90]90: [91]91: [92]92: [93]93: [94]94: [95]95: [96]96: [97]97: [98]98: [99]99: [100]length: 100[[Prototype]]: Array(0)[[Prototype]]: Object[[Prototype]]: Objectc: 76: {0: 'x'}78: {0: '.'}[[Prototype]]: Object[[Prototype]]: Object

I can see the actual UI patch at the end but there are also 100 entries in that ‘d:’ array. My final UI will have maybe 10k cells, that adds up in data being transmitted,

I’m pretty sure assigning a function call (in this case draw_cell) will prevent the change tracking from functioning as you expect. See Assigns and HEEx templates — Phoenix LiveView v1.0.4. You could try pre-building a map from the results of draw_cell, keyed by index, and use that in the assign. I haven’t tried it.

e.g.

def render(assigns) do
  cell_map = Enum.reduce(0..(grid_size(10, 10) - 1), %{}, fn idx, acc ->
    Map.put(idx, draw_cell(idx, 10, 10, assigns.snake_cells))
  end

  assigns = assign(assigns, :cell_map, cell_map)

  ~H"""
   ...
    <div class="snake_grid" id="game_surface" phx-window-keydown="key_pressed">
      <.live_component
        :for={idx <- 0..(grid_size(10, 10) - 1)}
        module={SnakeWeb.Cell}
        id={"cell_#{idx}"}
        content={@cell_map[idx]}
      />
    </div>
  ...
   """
end

Late to this conversation, but a quick general note about performance: functions like in (on a list) and Enum.at should be viewed with extreme suspicion if they’re used in a loop (ie the for here).

This is because they have to traverse the list to do their work, so they add an extra O(list length) cost to each call. Inside a loop that means turning an O(N) calculation into an O(N^2) and so on.

Using better data structures can help:

  • instead of using in on a large list, consider a MapSet which instead has O(log length) performance
  • for a sparsely-populated 2D grid, consider using a Map with keys shaped like {row, col} which has the same O(log length) behavior

Map can get unwieldy at large sizes with lots of writes, but it’s reasonably simple to swap in ETS if you get to that point.

3 Likes

I used this co-ordinate map approach in a LV project that rendered a flexbox grid a while back, and I seem to remember it working well—though I don’t recall if I had to do anything special in the HEEX to keep the diffs minimal.

fully aware of the inefficiency of looking for things in a list, at the moment I have 2 entries in it so its not really an issue, thatnks you for all the suggestions for fixing this when it gets bigger. For now I am trying to reduce the size of the transmitted diff packet

My point was not about list iteration vs map performance, but rather that by using the coordinate-map strategy I remember getting a smaller diff, though not if the map alone was fully sufficient to get the small diff or if I had to mix in other approaches.

I thought it /did/ work when using the :for heex syntax? or am I confused?

EDIT: I was indeed mistaken, and that linked post really clears it up. Also form the docs: “However, keep in mind LiveView does not track changes within the collection given to the comprehension. In other words, if one entry in @posts changes, all posts are sent again.”

I’m kind of surprised that I got this wrong. Maybe it’s worth a special callout somehow, or just italics/bold?