A Terminal Game in One File​

Runner features, not language. read_key, key_down, term_cols, term_rows, env_var, load_sound, play_sound and the file functions are registered by the standalone runner, jaiscript (the site's Play page registers the same console and sound names). jai_spider has only the file functions, and an engine embedded in another host has none of them unless that host registers its own (Embedding). print, sleep and time::stopwatch are the language's, on every engine.

With those, a real-time game is one .jai file. Every frame does four things: read the input, update the world, draw it as one string, wait out the rest of the frame. Nothing in the loop blocks.

The reference's runner host API has the full rules for each call, every name read_key returns included ("left", "esc", "space", a lowercase letter).

The whole game​

Rockfall: rocks fall, the @ dodges them with the arrow keys or a and d, and the score counts the rocks that missed. Save it as rockfall.jai and run jaiscript rockfall.jai.

jaiscript
// rockfall.jai: dodge the falling rocks. Arrows or a/d move, esc quits.
string ESC = "\x1b";
int cols = term_cols() < 60 ? term_cols() : 60;
int rows = term_rows() < 25 ? term_rows() - 2 : 23;
bool truecolor = env_var("COLORTERM") == "truecolor" || env_var("COLORTERM") == "24bit";

string color(int r, int g, int b) {
    if (truecolor) { return `${ESC}[38;2;${r};${g};${b}m`; }
    return `${ESC}[38;5;${16 + r / 51 * 36 + g / 51 * 6 + b / 51}m`;   // the xterm-256 colour cube
}

class Rock { int x = 0; float y = 0.0; float speed = 8.0; }
array<Rock> rocks;
random rng(7);
int player = cols / 2;
int score = 0;
var crash = load_sound("crash.wav");   // null without the file, or off Windows: the game runs silent

print(ESC + "[?1049h" + ESC + "[?25l" + ESC + "[2J", skip_newline);   // alternate screen, hidden cursor, cleared
time::stopwatch clock;
clock.start();
float step = 1.0 / 30.0;
bool running = true;
while (running) {
    float frame_start = clock.elapsed();

    for (string key = read_key(); key != ""; key = read_key()) {   // every press queued since the last frame
        if (key == "esc") { running = false; }
    }
    if ((key_down("left") || key_down("a")) && player > 0) { player -= 1; }
    if ((key_down("right") || key_down("d")) && player < cols - 1) { player += 1; }

    if (rng.next_int(0, 2) == 0) {
        Rock r;
        r.x = rng.next_int(0, cols - 1);
        r.speed = rng.next_float(6.0, 14.0);
        rocks.push(r);
    }
    for (auto& r : rocks) { r.y += r.speed * step; }
    for (auto& r : rocks) {
        if (r.y >= rows - 1 && r.y < rows && r.x == player) { running = false; }
    }
    int before = rocks.size();
    rocks.filter([](Rock r) { return r.y < rows; });   // keeps what is still falling
    score += before - rocks.size();

    string frame = ESC + "[H" + color(150, 150, 165);   // cursor home: each frame overwrites the last
    for (int y = 0; y < rows; ++y) {
        string line = string::repeat(" ", cols);
        for (auto& r : rocks) {
            if (math::ifloor(r.y) == y) { line[r.x] = '*'; }
        }
        if (y == rows - 1) {
            line = line.substr(0, player) + color(255, 140, 0) + "@" + color(150, 150, 165) + line.substr(player + 1);
        }
        frame += line + "\n";
    }
    frame += ESC + "[0m" + `score ${score}   esc quits` + ESC + "[K";
    print(frame, skip_newline);   // one write per frame, flushed

    float spent = clock.elapsed() - frame_start;
    if (spent < step) { sleep(step - spent); }
}
if (crash != null) { crash.play(); sleep(0.5); }
print(ESC + "[0m" + ESC + "[?25h" + ESC + "[?1049l", skip_newline);   // the main screen back, as it was
print(`You dodged ${score} rocks.`);

Reading the keyboard​

  • read_key() returns one press at a time from a queue, in order, without waiting, and "" once the queue is empty. Drain it every frame with a loop, as Rockfall does, so no press waits a frame behind another. Use it for what happens once per press: a menu choice, a jump, esc.
  • key_down(name) says whether a key is held at this moment. Use it for movement.
  • On Windows the console reports releases: a key reads as held exactly while it is down, and read_key also returns "shift", "ctrl" and "alt".
  • A POSIX terminal sends no releases: a key reads as held for half a second after its last press or auto-repeat, and "shift", "ctrl" and "alt" never read as held.

Frame pacing​

A stopwatch measures the frame and sleep waits out the rest of its slot (Time). The world moves by a fixed step per frame, so its speed does not depend on the machine:

jaiscript
time::stopwatch clock;
clock.start();
float step = 1.0 / 30.0;
for (int frame = 0; frame < 6; ++frame) {
    float frame_start = clock.elapsed();
    // input, update and drawing go here
    float spent = clock.elapsed() - frame_start;
    if (spent < step) { sleep(step - spent); }
}
print(`six frames took at least ${6 * step:.2f} s: ${clock.elapsed() >= 5.5 * step}`);
output
six frames took at least 0.20 s: true

sleep(seconds) takes a float; zero or less returns at once, so a frame that runs long skips its sleep. A game that must keep its world speed when frames run long moves the world by the measured time, clock.delta(), instead of the fixed step.

Drawing a frame​

  • Build each frame as one string and print it once with skip_newline, which adds no newline and flushes, so the frame appears whole.
  • Start the string with cursor-home so each frame overwrites the last without a clear and its flicker.
  • A frame built from several print calls ends all but the last with skip_flush (no newline, no flush) and the last with skip_newline (Hello & Basics).
  • Keep the frame one row shorter than the window so the terminal never scrolls.
  • term_cols() and term_rows() are the window's size in characters, read at each call (80 by 25 when it cannot be read). Rockfall reads them once and caps them at 60 by 23; a game that follows a resized window reads them each frame and clears the screen when they change.
  • On the way out, leave the alternate screen, show the cursor and reset the colours. The runner does the same for a script that stops through an error or Ctrl+C (Ctrl+C).
jaiscript
string ESC = "\x1b";
string frame_of(array<string> grid, int score) {
    string result = ESC + "[H";                  // cursor home
    for (auto& row : grid) { result += row + "\n"; }
    return result + `score ${score}` + ESC + "[K";   // [K clears the rest of the line
}
print(to_json(frame_of(["  *  ", "     ", "  @  "], 3)));
output
"\u001b[H  *  \n     \n  @  \nscore 3\u001b[K"

to_json shows the escape byte "\x1b" as \u001b. The sequences a game uses most:

SequenceEffect
ESC + "[?1049h", ESC + "[?1049l"enter and leave the alternate screen: the shell's text comes back afterwards
ESC + "[?25l", ESC + "[?25h"hide and show the cursor
ESC + "[H"move the cursor to the top-left corner
ESC + "[K"clear from the cursor to the end of the line, so a shorter line leaves nothing of a longer one

Clearing the screen and the colour sequences: ANSI escape sequences.

Sound​

  • load_sound(path) opens a WAV, MIDI or MP3 file and returns a Sound at once while the file loads in the background. Load each effect once: play() restarts it, play(true) loops it, volume(level) sets its level.
  • play_sound(path) loads and plays in one call, and the sound finishes even when the result is dropped.
  • Every Sound is its own voice, so music and effects play at once.
  • Sound plays on Windows and on the Play page. Elsewhere, and for a missing file, load_sound returns null: check for it, as Rockfall does, and the game runs silent.
  • A game can write its own effects: binary::writer builds a WAV file's bytes and write_file saves them (Binary data).

Saves and paths​

  • read_file and write_file resolve a relative path from the working directory; import and include resolve from the script's folder. Build a save path from root_script_dir() (beside the script) or app_data_dir(name) (per user) (Where files are).
  • to_json and from_json turn the game's state into text and back.
  • Write a temporary file, then move_file it over the old save: the save stays whole if the game stops mid-write.

Bigger games​

The example games (GLOOM, GRUBWELL, Gator Quest, JaiDoom) are this chapter at full size: one string per frame, read_key and key_down, and sound in all but GRUBWELL. Gator Quest also reloads its own scripts while it runs (Modules).