Small example "freewheeling" app that can be modified without restarting
-- A general architecture for free-wheeling, live programs:
--  on startup:
--    scan both the app directory and the save directory for files with numeric prefixes
--    load files in order
--
--  then start drawing frames on screen and reacting to events
--
--  events from keyboard and mouse are handled as the app desires
--
--  on incoming messages to a specific file, the app must:
--    determine the definition name from the first word
--    execute the value, returning any errors
--    look up the filename for the definition or define a new filename for it
--    save the message's value to the filename
--
--  if a game encounters a run-time error, send it to the driver and await
--  further instructions. The app will go unresponsive in the meantime, that
--  is expected. To shut it down cleanly, type C-q in the driver.

json = require 'json'

-- available modes: run, error
Mode = 'run'
Error_message = nil
Error_count = 0

-- we'll reuse error mode on load for an initial version check
Supported_versions = {'11.5', '11.4', '11.3', '11.2', '11.1', '11.0', '12.0'}  -- put the recommended version first
Version = nil

-- a namespace of "frameworky" things in addition to love.*
-- these can't be modified
app = {}

-- a namespace of frameworky callbacks
-- these will be modified live
on = {}

-- === on startup, load all files with numeric prefix

function love.load(args, commandline)
  -- version check
  local major, minor = love.getVersion()
  Version = major..'.'..minor
  if array.find(Supported_versions, Version) == nil then
    Mode = 'error'
    Error_message = ("This app doesn't support version %s; please use version %s. Press any key to try it with this version anyway."):format(Version, Supported_versions[1])
    print(Error_message)
    -- continue initializing everything; hopefully we won't have errors during initialization
  end

  app.freeze_all_existing_definitions()

  app.Filenames_to_load = {}  -- filenames in order of numeric prefix
  app.Filename = {}  -- map from definition name to filename (including numeric prefix)
  app.Final_prefix = 0
  app.load_files_so_far()

  -- hysteresis in a few places
  Current_time = 0
  app.Previous_read = 0
  app.Last_focus_time = 0
  app.Last_resize_time = 0

  love.window.setTitle('app')

  if on.initialize then on.initialize() end
end

function app.load_files_so_far()
  print('new edits will go to ' .. love.filesystem.getSaveDirectory())
  -- if necessary, copy files from repo to save dir
  if io.open(love.filesystem.getSaveDirectory()..'/0000-freewheeling-start') == nil then
    print('copying all definitions from repo to save dir')
    for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
      local numeric_prefix, root = filename:match('^(%d+)-(.+)')
      if numeric_prefix then
        local buf = love.filesystem.read(filename)
        print('copying', filename)
        love.filesystem.write(filename, buf)
      end
    end
  end
  -- load files to load in save dir
  -- (ignore the repo because we might have deleted definitions)
  for _,filename in ipairs(love.filesystem.getDirectoryItems('')) do
    if io.open(love.filesystem.getSaveDirectory()..'/'..filename) then
      local numeric_prefix, root = filename:match('^(%d+)-(.+)')
      if numeric_prefix and tonumber(numeric_prefix) > 0 then  -- skip 0000
        app.Filename[root] = filename
        table.insert(app.Filenames_to_load, filename)
        app.Final_prefix = math.max(app.Final_prefix, tonumber(numeric_prefix))
      end
    end
  end
  table.sort(app.Filenames_to_load)
  -- load files from save dir
  for _,filename in ipairs(app.Filenames_to_load) do
--?     print('loading', filename)
    local buf = love.filesystem.read(filename)
    assert(buf and buf ~= '')
    local _, definition_name = filename:match('^(%d+)-(.+)')
    local status, err = app.eval(buf, definition_name)
    if not status then
      error(err)
    end
  end
end

APP = 'fw_app'

-- === on each frame, check for messages and alter the app as needed

function love.update(dt)
  Current_time = Current_time + dt
  if Current_time < app.Last_resize_time + 0.1 then
    return
  end
  -- listen for commands in both 'error' and 'run' modes
  if Current_time - app.Previous_read > 0.1 then
    local buf = app.receive()
    if buf then
      local possibly_mutated = app.run(buf)
      if possibly_mutated then
        Mode = 'run'
      end
      if on.code_change then on.code_change() end
    end
    app.Previous_read = Current_time
  end
  if Mode == 'run' then
    if on.update then on.update(dt) end
  end
end

-- look for a message from outside, and return nil if there's nothing
function app.receive()
  local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
  if f == nil then return nil end
  local result = f:read('*a')
  f:close()
  if result == '' then return nil end  -- empty file == no message
  print('<='..app.color(--[[bold]]1, --[[blue]]4))
  print(result)
  print(app.reset_terminal())
  os.remove(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app')
  return result
end

function app.send(msg)
  local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver', 'w')
  if f == nil then return end
  f:write(msg)
  f:close()
  print('=>'..app.color(0, --[[green]]2))
  print(msg)
  print(app.reset_terminal())
end

function app.send_run_time_error(msg)
  local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver_run_time_error', 'w')
  if f == nil then return end
  f:write(msg)
  f:close()
  print('=>'..app.color(0, --[[red]]1))
  print(msg)
  print(app.reset_terminal())
end

-- args:
--   format: 0 for normal, 1 for bold
--   color: 0-15
function app.color(format, color)
  return ('\027[%d;%dm'):format(format, 30+color)
end

function app.reset_terminal()
  return '\027[m'
end

-- returns true if we might have mutated the app, by either creating or deleting a definition
function app.run(buf)
  local cmd = buf:match('^%s*(%S+)')
  assert(cmd)
  print('checking for command: '..cmd)
  if cmd == 'QUIT' then
    love.event.quit(1)
  elseif cmd == 'RESTART' then
    restart()
  elseif cmd == 'MANIFEST' then
    app.Filename[APP] = love.filesystem.getIdentity()
    app.send(json.encode(app.Filename))
  elseif cmd == 'DELETE' then
    local definition_name = buf:match('^%s*%S+%s+(%S+)')
    if app.Frozen_definitions[definition_name] then
      app.send('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be deleted.')
      return
    end
    if app.Filename[definition_name] then
      local index = array.find(app.Filenames_to_load, app.Filename[definition_name])
      table.remove(app.Filenames_to_load, index)
      app.eval(definition_name..' = nil')  -- ignore errors which will likely be from keywords like `function = nil`
      love.filesystem.remove(app.Filename[definition_name])
      app.Filename[definition_name] = nil
    end
    app.send('{}')
    return true
  elseif cmd == 'GET' then
    local definition_name = buf:match('^%s*%S+%s+(%S+)')
    local val, _ = app.get_binding(definition_name)
    if val then
      app.send(val)
    else
      app.send('ERROR no such value')
    end
  elseif cmd == 'GET*' then
    -- batch version of GET
    local result = {}
    for definition_name in buf:gmatch('%s+(%S+)') do
      print(definition_name)
      local val, _ = app.get_binding(definition_name)
      if val then
        table.insert(result, val)
      end
    end
    local delimiter = '\n==fw: definition boundary==\n'
    app.send(table.concat(result, delimiter)..delimiter)  -- send a final delimiter to simplify the driver's task
  elseif cmd == 'DEFAULT_MAP' then
    local contents = love.filesystem.read('default_map')
    if contents == nil then contents = '{}' end
    app.send(contents)
  -- other commands go here
  else
    local definition_name = buf:gsub('%-%-[^\n]*', ''):match('^%s*(%S+)')
    if definition_name == nil then
      -- contents are all Lua comments; we don't currently have a plan for them
      app.send('ERROR empty definition')
      return
    end
    print('definition name is '..definition_name)
    if app.Frozen_definitions[definition_name] then
      app.send('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be safely edited live.')
      return
    end
    local status, err = app.eval(buf, definition_name)
    if not status then
      -- throw an error
      app.send('ERROR '..tostring(err))
      return
    end
    -- eval succeeded without errors; persist the definition
    local filename = app.Filename[definition_name]
    if filename == nil then
      app.Final_prefix = app.Final_prefix+1
      filename = ('%04d-%s'):format(app.Final_prefix, definition_name)
      table.insert(app.Filenames_to_load, filename)
      app.Filename[definition_name] = filename
    end
    love.filesystem.write(filename, buf)
    app.send('{}')
    return true
  end
end

-- Everything that exists before we start loading the live files is frozen and
-- can't be edited live.
function app.freeze_all_existing_definitions()
  app.Frozen_definitions = {on=true}  -- special case for version 1
  local done = {}
  done[app.Frozen_definitions]=true
  app.freeze_all_existing_definitions_in(_G, {}, done)
end

function app.freeze_all_existing_definitions_in(tab, scopes, done)
  -- track duplicates to avoid cycles like _G._G, _G._G._G, etc.
  if done[tab] then return end
  done[tab] = true
  for name,binding in pairs(tab) do
    local full_name = app.full_name(scopes, name)
--?     print(full_name)
    app.Frozen_definitions[full_name] = true
    if type(binding) == 'table' and full_name ~= 'package' then  -- var 'package' contains copies of all modules, but not the best name; rely on people to not modify package.loaded.io.open, etc.
      table.insert(scopes, name)
      app.freeze_all_existing_definitions_in(binding, scopes, done)
      table.remove(scopes)
    end
  end
end

function app.full_name(scopes, name)
  local ns = table.concat(scopes, '.')
  if #ns == 0 then return name end
  return ns..'.'..name
end

function app.get_binding(name)
  if app.Filename[name] then
    return love.filesystem.read(app.Filename[name])
  end
end

-- Wrapper for Lua's weird evaluation model.
-- Lua is persnickety about expressions vs statements, so we need to do some
-- extra work to get the result of an evaluation.
-- return values:
--  all well -> true, ...
--  load failed -> nil, error message
--  run (pcall) failed -> false, error message
function app.eval(buf, name)
  -- We assume a program is either correct with 'return' prefixed xor not.
  -- Is this correct? Who knows! But the Lua REPL does this as well.
  local f = load('return '..buf, name or 'REPL')
  if f then
    return pcall(f)
  end
  local f, err = load(buf, name or 'REPL')
  if f then
    return pcall(f)
  else
    return nil, err
  end
end

-- all other love callbacks can just be delegated to the on.* namespace
-- just easier to remember
-- how to make these discoverable?

function love.draw()
  if Mode == 'error' then
    love.graphics.setColor(0,0,1)
    love.graphics.rectangle('fill', 30, 30, 1000, 80)
    love.graphics.setColor(1,1,1)
    love.graphics.printf(Error_message, 40, 40, 600)
    return
  end
  if on.draw then on.draw() end
end

function love.quit()
  if Mode == 'error' then return end
  if on.quit then on.quit() end
end

function restart()
  if on.quit then on.quit() end
  if on.initialize then on.initialize() end
end

function love.resize(w,h)
  if Mode == 'error' then return end
  screen = {w=w, h=h}
  if on.resize then on.resize(w,h) end
  app.Last_resize_time = Current_time
end

function love.filedropped()
  if Mode == 'error' then return end
  if on.filedropped then on.filedropped() end
end

function love.focus(in_focus)
  if Mode == 'error' then return end
  if in_focus then
    app.Last_focus_time = Current_time
  end
  if on.focus then on.focus(in_focus) end
end

function love.keypressed(key, scancode, isrepeat)
  if Mode == 'error' then
    if key == 'c' then
      love.system.setClipboardText(Error_message)
    end
    return
  end
  -- ignore events for some time after window in focus (mostly alt-tab)
  if Current_time < app.Last_focus_time + 0.01 then
    return
  end
  if on.key_press then on.key_press(key, scancode, isrepeat) end
end

function love.textinput(t)
  if Mode == 'error' then return end
  -- ignore events for some time after window in focus (mostly alt-tab)
  if Current_time < app.Last_focus_time + 0.01 then
    return
  end
  if on.text_input then on.text_input(t) end
end

function love.keyreleased(key, scancode)
  if Mode == 'error' then
    Mode = 'run'
    return
  end
  if on.key_release then on.key_release(key, scancode) end
end

function love.mousepressed(x,y, mouse_button)
  if Mode == 'error' then return end
  if on.mouse_press then on.mouse_press(x,y, mouse_button) end
end

function love.mousereleased(x,y, mouse_button)
  if Mode == 'error' then return end
  if on.mouse_release then on.mouse_release(x,y, mouse_button) end
end

function love.mousemoved(x,y, dx,dy, istouch)
  if Mode == 'error' then return end
  if on.mouse_move then on.mouse_move(x,y, dx,dy, istouch) end
end

function love.wheelmoved(dx,dy)
  if Mode == 'error' then return end
  if on.mouse_wheel_move then on.mouse_wheel_move(dx,dy) end
end

-- === on error, pause the app and wait for messages

function love.run()
  love.load(love.arg.parseGameArguments(arg), arg)
  love.timer.step()
  local dt = 0

  return function()
    local status, result = xpcall(app.try_run, app.handle_error)
    return result
  end
end

-- one iteration of the event loop
-- return nil to continue the event loop, non-nil to quit
-- from https://love2d.org/wiki/love.run
function app.try_run()
  if love.event then
    love.event.pump()
    for name, a,b,c,d,e,f in love.event.poll() do
      if name == 'quit' then
        if not love.quit() then
          return a or 0
        end
      end
      love.handlers[name](a,b,c,d,e,f)
    end
  end

  dt = love.timer.step()
  love.update(dt)

  love.graphics.origin()
  love.graphics.clear(love.graphics.getBackgroundColor())
  love.draw()
  love.graphics.present()

  love.timer.sleep(0.001)

  -- returning nil continues the loop
end

-- return nil to continue the event loop, non-nil to quit
function app.handle_error(err)
  Mode = 'error'
  local callstack = debug.traceback('', --[[stack frame]]2)
  local cleaned_up_error = 'Error: ' .. cleaned_up_frame(tostring(err))..'\n'..cleaned_up_callstack(callstack)
  app.send_run_time_error(cleaned_up_error)
  love.graphics.setFont(love.graphics.newFont(20))
  Error_message = 'Something is wrong. Sorry!\n\n'..cleaned_up_error..'\n\n'..
    "(Note: function names above don't include outer tables. So functions like on.draw might show up as just 'draw', etc.)\n\n"..
    'Options:\n'..
    '- press "c" (without the quotes) to copy this message to your clipboard to send to me: ak@akkartik.com\n'..
    '- press any other key to retry, see if things start working again\n'..
    '- run driver.love to try to fix it yourself. As you do, feel free to ask me questions: ak@akkartik.com\n'
  Error_count = Error_count+1
  if Error_count > 1 then
    Error_message = Error_message..('\n\nThis is error #%d in this session; things will probably not improve in this session. Please copy the message and send it to me: ak@akkartik.com.'):format(Error_count)
  end
  print(Error_message)
end

-- I tend to read code from files myself (say using love.filesystem calls)
-- rather than offload that to load().
-- Functions compiled in this manner have ugly filenames of the form [string "filename"]
-- This function cleans out this cruft from error callstacks.
-- It also strips out the numeric prefixes we introduce in filenames.
function cleaned_up_callstack(callstack)
  local frames = {}
  for frame in string.gmatch(callstack, '[^\n]+\n*') do
    table.insert(frames, cleaned_up_frame(frame))
  end
  -- the initial "stack traceback:" line was unindented and remains so
  return table.concat(frames, '\n\t')
end

function cleaned_up_frame(frame)
  local line = frame:gsub('^%s*(.-)\n?$', '%1')
  local filename, rest = line:match('([^:]*):(.*)')
  return cleaned_up_filename(filename)..':'..rest
end

function cleaned_up_filename(filename)
  -- pass through frames that don't match this format
  -- this includes the initial line "stack traceback:"
  local core_filename = filename:match('^%[string "(.*)"%]$')
  if core_filename == nil then return filename end
  -- strip out the numeric prefixes we introduce in filenames
  local _, core_filename2 = core_filename:match('^(%d+)-(.+)')
  return core_filename2 or core_filename
end

-- some abbreviations
graphics = love.graphics

color = graphics.setColor
line = graphics.line
rect = graphics.rectangle
circ = graphics.circle
ellipse = graphics.ellipse
-- also screen is set in resize above (which seems to also be called during initialization)

function starts_with(s, prefix)
  if #s < #prefix then
    return false
  end
  for i=1,#prefix do
    if s:sub(i,i) ~= prefix:sub(i,i) then
      return false
    end
  end
  return true
end

array = {}

function array.find(arr, elem)
  if type(elem) == 'function' then
    for i,x in ipairs(arr) do
      if elem(x) then
        return i
      end
    end
  else
    for i,x in ipairs(arr) do
      if x == elem then
        return i
      end
    end
  end
  return nil
end