screeps-api
    Preparing search index...

    This example uses the HTTP API and the WebSocket API to implement a rudimentary text-based client that runs in a terminal.

    Client Screenshot

    The HTTP API is used to fetch room terrain, while the WebSocket API is used to stream updates to room objects and all other client state.

    This script will abort with an error when the dimensions of the terminal it is running in are too small.

    Type the name of a room to begin rendering it. Input that does not match the format of a room name will be evaluated as an expression on the client's default shard.

    import readline from 'node:readline/promises'
    import { fileURLToPath } from 'node:url'
    // If installed from npm, use:
    // import { ... } from 'screeps-api'
    import { Resources, RoomEvent, RoomObject, RoomObjectType, RoomObjectTypes, UserConsoleEvent, UserCpuEvent, UserCpuEventData } from '../src'

    // Borrow API client instance and terminal I/O from the Console example
    import { api, output, rl, stripTags, quit } from './console'

    const ROOM_DIM = 50
    const MIN_COLS = ROOM_DIM
    const MIN_ROWS = ROOM_DIM + 5

    // Abort if terminal is too small to render a room
    if (output.columns < MIN_COLS || output.rows < MIN_ROWS) {
    console.error(`Expected terminal size to be at least ${MIN_COLS} columns by ${MIN_ROWS} rows`)
    process.exit(1)
    }

    const rlOut = new readline.Readline(output)

    /** Additional room object properties used to render them */
    interface RenderedObject extends RoomObject {
    glyph: string
    glyphOrder: number
    }

    let cpuData: Partial<UserCpuEventData> = {}
    let gameTime: number | undefined
    let roomName: string | undefined
    let terrain: string = ''
    let objects: { [_id: string]: RenderedObject } = {}

    const TERRAIN_GLYPHS: Readonly<{ [code: string]: string }> = {
    '0': ' ', // plain
    '1': '#', // wall
    '2': '.' // swamp
    }

    /** Glyphs used to represent each {@link RoomObject} type. */
    const OBJECT_GLYPHS: Readonly<{ [resType in RoomObjectType]: string }> = {
    // Assign `r` to all dropped resources
    ...Object.values(Resources).reduce(
    (glyphs, resType) => { glyphs[resType] = 'r' ; return glyphs },
    {} as { [resType in RoomObjectType]: string },
    ),
    creep: 'c',
    powerCreep: 'p',
    deposit: 'D',
    mineral: 'm',
    source: 'S',
    constructedWall: '$',
    container: 'o',
    controller: 'C',
    extension: 'e',
    extractor: 'M',
    factory: 'f',
    invaderCore: 'I',
    keeperLair: 'L',
    lab: 'l',
    link: '/',
    nuker: '%',
    observer: 'I',
    portal: '>',
    powerBank: 'B',
    powerSpawn: 'P',
    rampart: '@',
    road: '_',
    spawn: 'C',
    storage: 'O',
    terminal: 'T',
    tower: 't',
    constructionSite: '^',
    nuke: 'v',
    ruin: 'X',
    tombstone: 'x'
    };

    const GLYPH_RENDER_ORDER: Readonly<{ [resType in RoomObjectType]: number }> = {
    // Assign a default value
    ...Object.values(RoomObjectTypes).reduce(
    (glyphs, resType) => { glyphs[resType] = 50 ; return glyphs },
    {} as { [resType in RoomObjectType]: number },
    ),
    nuke: 5,
    container: 10,
    road: 10,
    ruin: 20,
    tombstone: 20,
    rampart: 60,
    constructionSite: 65,
    constructedWall: 70,
    controller: 100,
    extension: 100,
    extractor: 100,
    factory: 100,
    invaderCore: 100,
    keeperLair: 100,
    lab: 100,
    link: 100,
    nuker: 100,
    observer: 100,
    portal: 100,
    powerBank: 100,
    powerSpawn: 100,
    spawn: 100,
    storage: 100,
    terminal: 100,
    tower: 100,
    creep: 200,
    powerCreep: 200
    }

    async function changeRoom (newRoomName: string) {
    // If on an official server, normalize room names by prepending shard names
    let newFullRoomName = newRoomName
    if (api.isOfficialServer && !newRoomName.includes('/')) {
    const shardName = api.appConfig.defaultShard
    if (!shardName) {
    console.error(`Room name must be prefixed with a shard name because api.appConfig.defaultShard is not set`)
    return
    }
    newFullRoomName = `${api.appConfig.defaultShard}/${newRoomName}`
    }

    if (roomName === newFullRoomName) {
    console.info(`${newFullRoomName} is already being shown`)
    return
    }

    if (roomName) {
    api.socket.unsubscribe(`room:${roomName}`, updateRoomObjects)
    }

    roomName = newFullRoomName
    terrain = (await api.gameRoomTerrain(newRoomName)).terrain[0].terrain
    objects = {}

    api.socket.subscribe(`room:${roomName}`, updateRoomObjects)

    await renderPrompt()
    }

    async function updateRoomObjects (event: RoomEvent) {
    const fullRoomName = event.path ? `${event.id}/${event.path}` : event.id
    if (fullRoomName !== roomName) {
    console.warn(`Ignoring room event for ${fullRoomName}; expected ${roomName}`)
    return
    }

    gameTime = event.data.gameTime

    // Add/update objects
    for (const id in event.data.objects) {
    // Assign RoomObject properties
    const updated = event.data.objects[id]
    objects[id] ??= updated as RenderedObject
    Object.assign(objects[id], updated)

    // Assign RenderedObject properties
    const obj = objects[id]
    obj.glyph = OBJECT_GLYPHS[obj.type]
    obj.glyphOrder = GLYPH_RENDER_ORDER[obj.type]
    }

    // // Clear old objects:
    // for (const id in objects) {
    // // TODO:
    // // - Remove creeps/powerCreeps if gameTime >= obj.gameTime
    // // - Check if the value of event.data.objects[id] is null
    // // to indicate a missing object
    // }

    await render()
    await renderPrompt()
    }

    async function clearScreen() {
    rlOut.cursorTo(0, 0)
    rlOut.clearScreenDown()
    await rlOut.commit()
    }

    async function render () {
    await clearScreen()
    await renderStats()

    // Top-left coordinate of the room display
    const roomX = 0
    const roomY = 1

    // Render room terrain
    rlOut.cursorTo(roomX, roomY)
    await rlOut.commit()
    for (let y = 0; y < ROOM_DIM; y++) {
    const i = y * ROOM_DIM
    const terrainStr = terrain.substring(i, i + ROOM_DIM)
    .split('')
    .map(c => TERRAIN_GLYPHS[c])
    .join('')
    output.write(terrainStr + '\n')
    }

    // Render objects from lowest to highest priority to ensure glyphs
    // for higher-priority objects obscure those of lower-priority objects.
    const objs = Object.values(objects)
    .sort((a, b) => a.glyphOrder - b.glyphOrder)
    for (const obj of objs) {
    rlOut.cursorTo(obj.x + roomX, obj.y + roomY)
    await rlOut.commit()
    output.write(obj.glyph)
    }
    }

    async function renderStats () {
    // Render basic stats
    rlOut.cursorTo(0, 0)
    rlOut.clearLine(0)
    await rlOut.commit()
    output.write(roomName ? `Room: ${roomName}` : 'Enter a room name')
    rlOut.cursorTo(26, 0)
    await rlOut.commit()
    output.write(`CPU: ${cpuData.cpu ?? '---'}`)
    rlOut.cursorTo(35, 0)
    await rlOut.commit()
    const memKibUsed = cpuData.memory !== undefined
    ? `${(cpuData.memory / 1024).toFixed(1)} KiB`
    : '---'
    output.write(`Memory: ${memKibUsed}`)
    // TODO: Render game time
    }

    const consoleLines = new Array()

    async function renderConsole() {
    // Remove oldest messages if log has overflowed
    const consoleRows = output.rows - ROOM_DIM - 2
    if (consoleLines.length > consoleRows) {
    consoleLines.splice(0, consoleLines.length - consoleRows)
    }

    // Render console output
    rlOut.cursorTo(0, ROOM_DIM + 1)
    for (const line of consoleLines) {
    rlOut.clearLine(0)
    await rlOut.commit()
    output.write(line.substring(0, output.columns) + '\n')
    }
    }

    async function renderPrompt() {
    rlOut.cursorTo(0, output.rows - 1)
    rlOut.clearLine(0)
    await rlOut.commit()
    rl.prompt(true)
    }

    function run() {
    api.socket.subscribe('console', async (event: UserConsoleEvent) => {
    const { messages, error, shard } = event.data
    const shardTag = shard ? `[${shard}] ` : ''

    if (!messages) return

    // Add newest console messages
    const consoleRows = output.rows - ROOM_DIM - 2
    const newMessages = [
    ...(error ? [`${shardTag}${error}`.split('\n')] : []),
    ...messages.results.flatMap(msg => `< ${msg}`.split('\n')),
    ...messages.log.flatMap(msg => `${shardTag}${stripTags(msg)}`.split('\n'))
    ].slice(-consoleRows)
    consoleLines.push(...newMessages)

    await renderConsole()
    await renderPrompt()
    })

    api.socket.subscribe('cpu', async (event: UserCpuEvent) => {
    cpuData = event.data
    await renderStats()
    await renderPrompt()
    })

    rl.on('close', () => quit('I/O closed. Bye!'))
    rl.on('SIGINT', () => quit('Keyboard interrupt. Bye!'))

    rl.on('line', async (line) => {
    line = line.trim()

    if (line == 'exit') {
    quit()
    }

    if (/^(?:(\w+)\/)?(E|W)(\d+)(N|S)(\d+)$/.exec(line)) {
    await changeRoom(line)
    return
    }

    api.userConsole(line).catch(console.error)
    })

    api.socket.connect()
    }

    if (fileURLToPath(import.meta.url) === process.argv[1]) {
    run()
    }