THIS IS UNFINISHED AND UNPUBLISHED. READ AT YOUR OWN RISK.

reflect

A software article by Efron Licht July 2023

Our goals

Arbitrary manipulation of gamestate at runtime with a forgiving API. Ideally, the combination of console and other debug tools should mean there’s no difference between ‘playing’ the game and ‘building/debugging’ the game.

High-level goals:

performance

‘idle’ performance should be as close to zero as possible. The console should not affect performance unless it is being used.

the console should start up instantly and not affect the game’s startup time: hard max of 5ms. doing a eager-nonblocking load and not letting the console open on the first frame is totally fine.

Commands should be reasonably performant.

OTOH, any ‘callback’ behavior that triggers on gamestate changes should be as fast as possible, since it can be triggered arbitrarily often. it is OK for debugging tools to update less often than the game does: the console should NEVER drop a frame except immediately after processing a new command.

example uses of the console

modify UI live

‘cheat codes’:

enable or disable UI elements

connect variables to player input (mouse position, keyboard input, etc)

symbol name unicode
up arrow U+2191
down arrow U+2193
left arrow U+2190
right arrow U+2192
shift U+21E7
control U+2303

console UI

traditional console UI: command history, autocomplete, etc. history is saved to disk.

mod keys action note
↑/↓ scroll through command history
tab autocomplete
←/→ move cursor
backspace
←/→ move cursor by word
←/→ move cursor to beginning/end of line
delete word
delete line
c copy line to system keyboard
v paste line to system keyboard

/* ALIAS Op = iota // alias a key to another key CALL // call a function ENV // print environment variables CPIN // Pin a value to a constant literal RPIN // Pin a value to reference another key. if self-referential, it will ‘freeze’ the value. // TODO: OPIN? pin with operator, like “pin player.X npc[0].X + 20” TOGGLE // toggle a boolean value FOLLOWMOUSE // set a value to follow the mouse position until followmouse is called again FLATWATCH // watch all the values within a struct or field for changes HELP // print help LIST // list possible operations LOAD // load a value from a file. only ‘.json’ for now. MOD // modify a value using an operator PRINT // print a value SAVE // save a value to a file. only ‘.json’ for now. CONCATLOAD // load a value from a file and concatenate it with the current valueg DESTROY // destroy NPCS, WALLS, PROJECTILES, PICKUPS, or ALL of them RESTART // restart the game SET // set a value SETMOUSE // set a value to the mouse position UNALIAS // unbind all aliases UNWATCH // stop watching all values UNPIN // unpin one or more values WATCH // watch a value for changes OP_N // number of operations: must be last */

console commands

command description example notes
ALIAS create an alias for a command alias px player.x px will be expanded to player.x
CALL call a method or function field call player.refillhp set the player’s hp to it’s normal max
CPIN pin a value to a constant literal cpin player.x 100 player.x will always be 100
RPIN pin a value to reference another key rpin player.x player.y player.x will always be the same as player.y
TOGGLE toggle a boolean value toggle ui.healthbar
FOLLOWMOUSE set a value to follow the mouse position until followmouse is called again followmouse ui.healthbar healthbar follows the mouse
FLATWATCH watch all the values within a struct or field for changes flatwatch player watch all the values within player (player.hp, player.x, player.y, etc) for changes
HELP print help help
LIST list patterns, ops, or saves list ops list all the operations
LOAD load a value from a file load player player.json load player from player.json
MOD modify a value using an operator mod player.x += 20
PRINT print a value print player.x
SAVE save a value to a file save player player.json save player to player.json
DESTROY destroy NPCs, walls, projectiles, pickups, or all of them destroy npcs destroy all NPCs
RESTART restart the game restart
SET set a value set player.x 100
SETMOUSE set a value to the mouse position setmouse player.x set player.x to the mouse position
UNALIAS unbind all aliases unalias unbind all aliases
UNWATCH stop watching all values unwatch stop watching all values
UNPIN unpin one or more values unpin player.x unpin player.x
WATCH watch a value for changes watch player.x watch player.x for changes

forgiving API

implementing the console

building autocomplete

CLIs without autocomplete are a pain to use. Autocomplete not only saves on typing, it helps a user discover what commands are available.

discovery-through-autocompltee

We’d like to have a variety of autocompletes available depending on what kind of operation the player is attempting.

For example, for the FIRST word in a prompt, we might want to suggest operations and fields, since all of the following are valid operations:

tog
player.x *= 2 # first word is a field, for a 'modify-in-place' operation

We also might want to suggest known savefiles for the load and save operations, like this:

// completions is a struct that holds all the possible completions for a given prompt.
// see suggestedCompletion for how it's used.
type completions struct {
  ops    []string // operations: toggle, mod, set, etc. only valid for the first word in a prompt.
  fields []string // fields and methods: player.x, ui.healthbar.enabled, etc. valid for any word in a prompt.
  // augment-assignment operators:  +=, -=, *=, /=, =, &=, |=, ^=, %=, <<=, >>=, &^=
  assignops []string 

  saveFiles []string // valid for the third word in a prompt, during 'save' or 'load'  

  // aliases is a map from alias to the aliased value.
  aliases map[string]string 
}

func suggestCompletion(c *completions, line string, cursor int) string {
		if cursor < len(line) {
      return "" // cursor is in the middle of a line: no suggestions
		} 
  
    // what word are we completing?
    i := strings.IndexAny(line, " \t\n") // position of first whitespace, if any
    for i+1 < len(line) && (line[i+1] == ' ' || line[i+1] == '\t') {
      i++ // skip whitespace
    }
    j := strings.LastIndexAny(line, " \t\n\r") // position of last whitespace, if any

    switch {
    case i == -1: // first word: choose an op or field. ops take priority.
      if completion := autocomplete(c.completions.ops, line); completion != "" {
        return completion
      }

    case i == j: // second word.
        firstWord, rem := line[:i], line[i:]

        // was the first word an alias?
        if alias, ok := c.aliases[firstWord]; ok {
           firstWord = alias // yes: replace it with the alias
        }

        // is the first word an op?
        if k := sort.SearchStrings(c.completions.ops, firstWord); k >= 0 && k < len(c.completions.ops) && c.completions.ops[k] == firstWord {
          // yes: autocomplete the rest of the line as a field. e.g, "toggle ui.healthbar"
          return line[:i+1] + autocomplete(c.completions.fields, strings.TrimSpace(rem))
        }

        // was the first word a field?
        if k := sort.SearchStrings(c.completions.fields, firstWord); k >= 0 && k < len(c.completions.fields) && c.completions.fields[k] == firstWord {
          // yes: allow autocomplete for augment-assignment operators. e.g, "player.x += player.y"
          return line[:i+1] + autocomplete(c.completions.assignops, strings.TrimSpace(rem))
        }

        // we don't know: allow autocomplete for fields anyways.
        return line[:i+1] + autocomplete(c.completions.fields, strings.TrimSpace(rem))
    case strings.Contains(line, "save"),  strings.Contains(line, "load"): 
      // third word during 'save' or 'load': choose a file
        return line[:j+1] + autocomplete(c.saveFiles, line[j+1:])
    default: // third word, not during 'save' or 'load': choose a field, like for 'player.x += player.y'
        return line[:j+1] + autocomplete(c.completions.fields, line[j+1:])
    }
}