THIS IS UNFINISHED AND UNPUBLISHED. READ AT YOUR OWN RISK.
A software article by Efron Licht July 2023
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:
‘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.
player.hp = 1000000
followmouse ui.healthbar
followmouse player
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 |
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 */
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 |
reset
at very leastnpcs.0
<==> npcs[0]
npcs.-1
<==> npcs[len(npcs)-1]
npcs = []struct{foo map[string]int}: npcs.0.foo.bar <==>
npcs[0].foo[“bar”]`toggle ui.healthbar
over ui.healthbar.enabled = !ui.healthbar.enabled
followmouse ui.healthbar
over follow(ui.healthbar, mouse)
reflect.Value.CanAddr()
.CLIs without autocomplete are a pain to use. Autocomplete not only saves on typing, it helps a user discover what commands are available.
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:])
}
}