Edit in JSFiddle

const canvas = document.querySelector("canvas"),
  $ = canvas.getContext("2d"),
  WIDTH = canvas.width = window.innerWidth,
  HEIGHT = canvas.height = window.innerHeight,
  FONT_WIDTH = 12,
  COUNT_OF_COLUMNS = Math.floor(WIDTH / FONT_WIDTH),
  CHANCE = 0.5,
  UPDATE_CURVE_INTERVAL = 15,
  RENDER_INTERVAL_MS = 150,
  TEXT_COLOR = "#0f0";

$.font = FONT_WIDTH + "px system-ui";

const ALPHABET = (
  'abcdefghijklmnopqrstuvwxyz' +
  'abcdefghijklmnopqrstuvwxyz'.toUpperCase() +
  '0123456789' +
  '!@#$%^&*-+'
).split('');

let currentCurve = Array(COUNT_OF_COLUMNS), 
    updateIterator = UPDATE_CURVE_INTERVAL, 
    eventQueue = [],
    listenToEvents = false;

function createCurve(columns, height) {
  const curve = [];

  for (let index = 0; index < columns; index++) curve[index] = [
    WIDTH / COUNT_OF_COLUMNS * index, // X-coordinate
    // Я так же в ручную уменьшаю значение коордианты Y на 20 пикселей.
    // Без этого верхняя часть канваса в момент работы кажется менее
    // живой и заполненной. Почему-то рандом, создающий кривую, 
    // чаще выводит координаты, что ближе к концу холста, и отнимание
    // 20 пикселей от результата немного компенсирует это. 
    Math.floor(Math.random() * height) - 20 // Y-coordinate 
  ];

  return curve;
}

const getRandomMember = arr => arr[Math.floor(Math.random() * arr.length)];

async function drawBackground() {
  // Если кривая была зарендерена число раз, записанное 
  // в UPDATE_CURVE_INTERVAL, то пришло время нарисовать
  // создать кривую с новыми координатами.
  if (updateIterator === UPDATE_CURVE_INTERVAL)
    updateIterator = 0, 
    currentCurve = createCurve(COUNT_OF_COLUMNS, HEIGHT);
  
  $.fillStyle = "rgba(0, 0, 0, .05)";
  $.fillRect(0, 0, WIDTH, HEIGHT);

  $.fillStyle = TEXT_COLOR;
  
  currentCurve = currentCurve.map(([ x, y ]) => {
    // Шанс (CHANCE) здесь отвечает за то, чтобы между буквами,
    // когда они смещаются по координате Y, оставились пропуски.
    if (CHANCE > Math.random()) $.fillText(getRandomMember(ALPHABET), x, y);
    
    // Здесь инкрементируем координату Y на каждой вершине кривой,
    // чтобы кривая спускалась вниз при каждой отрисовке.
    return [x, y + FONT_WIDTH];
  });
  
  updateIterator++;
}

async function drawByCursor() {
	eventQueue.map(event => {
  	const [ x, y ] = event.coords;
    
    // Если обрабатываемая линия была отрендерена нужное
    // количество раз, то мы её удаляем из очереди с 
    // помощью фильтра и ничего не рисуем.
    if (event.rendered > UPDATE_CURVE_INTERVAL) {
      eventQueue = eventQueue.filter(e => e !== event);
      return;
    }
    
  	if (CHANCE > Math.random()) $.fillText(getRandomMember(ALPHABET), x, y);
    
    // Для красоты, за каждый цикл рендеринга добавляем
    // не единицу к количеству требуемых рендеров, 
    // а лишь только половину, чтобы нарисованные линии выглядели
    // более длинными, чем те, что на бэкграунде.
    event.rendered += 0.5;
    event.coords[1] += FONT_WIDTH;
  });
}

window.addEventListener('mousedown', ({ clientX, clientY }) => {
	listenToEvents = true;
  
  eventQueue.push({ 
    coords: [ clientX, clientY ], 
    rendered: 0,
  });
});

window.addEventListener('click', ({ clientX, clientY }) => {
		listenToEvents = false;
    
    eventQueue.push({ 
  		coords: [ clientX, clientY ], 
      rendered: 0,
    });
});

window.addEventListener('mousemove', ({ clientX, clientY }) => {
    if (listenToEvents) eventQueue.push({ 
  		coords: [ clientX, clientY ], 
      rendered: 0,
    });
});

setInterval(async () => {
	drawBackground();
  drawByCursor();
}, RENDER_INTERVAL_MS);

<canvas></canvas>
body {
  margin: 0;
  overflow: hidden;
  background: black;
}