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'
    // If installed from npm, use:
    // import { ... } from 'screeps-api'
    import { Resources, RoomEvent, RoomObject, RoomObjectType, RoomObjectTypes, ScreepsHttpClient, ScreepsSocketClient, ServerAuthEvent, ServerAuthStatuses, UserConsoleEvent, UserCpuEvent, UserCpuEventData } from '../src'

    const ROOM_DIM = 50
    const MIN_COLS = ROOM_DIM
    const MIN_ROWS = ROOM_DIM + 2

    const input = process.stdin
    const output = process.stdout
    const rlOut = new readline.Readline(output)

    // 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)
    }

    // Load server/app names from env vars
    const serverName = process.env.SCREEPS_SERVER ?? 'main'
    const appName = process.env.SCREEPS_APP ?? 'example'
    const api = await ScreepsHttpClient.fromConfig(serverName, { app: appName })

    /** 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 terrain: string | undefined
    let objects: { [_id: string]: RenderedObject } = {}
    let roomName: string | undefined

    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(roomNameInput: string) {
    // Pull shard and room name from input and config
    // eslint-disable-next-line prefer-const
    let [newShardName, newRoomName] = roomNameInput.includes('/')
    ? roomNameInput.split('/')
    : [undefined, roomNameInput]
    newShardName ??= api.appConfig.defaultShard

    // If on an official server, reject inputs without shard names
    if (api.isOfficialServer && !newShardName) {
    console.error(`Room name must be prefixed with a shard name because api.appConfig.defaultShard is not set`)
    return
    }

    const newFullRoomName = api.isOfficialServer
    ? `${newShardName}/${newRoomName}`
    : newRoomName
    if (roomName === newFullRoomName) {
    console.info(`${newFullRoomName} is already being shown`)
    return
    }

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

    if (roomName) {
    void api.socket.unsubscribeRoom(newRoomName, newShardName, updateRoomObjects)
    }

    void api.socket.subscribeRoom(newRoomName, newShardName, updateRoomObjects)

    await renderStats()
    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/remove objects
    for (const id in event.data.objects) {
    // Delete removed objects
    if (event.data.objects[id] === null) {
    delete objects[id]
    continue
    }

    // 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]
    }

    await renderRoom()
    await renderPrompt()
    }

    /** Clear the screen */
    async function clearScreen() {
    rlOut.cursorTo(0, 0)
    rlOut.clearScreenDown()
    await rlOut.commit()
    }

    /** Render entire client UI */
    async function render() {
    await clearScreen()
    await renderStats()
    await renderRoom()
    await renderConsole()
    await renderPrompt()
    }

    async function renderStats() {
    // Clear line and display current room name
    rlOut.cursorTo(0, 0)
    rlOut.clearLine(0)
    await rlOut.commit()
    output.write(roomName ? `Room: ${roomName}` : 'Enter a room name')

    // Display CPU usage
    rlOut.cursorTo(26, 0)
    await rlOut.commit()
    output.write(`CPU: ${cpuData.cpu ?? '---'}`)

    // Display memory usage
    rlOut.cursorTo(35, 0)
    await rlOut.commit()
    const memKibUsed = cpuData.memory !== undefined
    ? `${(cpuData.memory / 1024).toFixed(1)} KiB`
    : '---'
    output.write(`Memory: ${memKibUsed}`)

    // Display game time
    rlOut.cursorTo(55, 0)
    await rlOut.commit()
    output.write(`Time: ${gameTime?.toLocaleString() ?? '---'}`)
    }

    async function renderRoom() {
    // Top-left coordinate of the room display
    const roomX = 0
    const roomY = 1

    // Render room terrain
    for (let y = 0; y < ROOM_DIM; y++) {
    rlOut.cursorTo(roomX, roomY + y)
    await rlOut.commit()

    const i = y * ROOM_DIM
    const terrainStr = terrain?.substring(i, i + ROOM_DIM)
    .split('')
    .map(c => TERRAIN_GLYPHS[c])
    .join('') ?? ' '.repeat(ROOM_DIM)
    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)
    }
    }

    const consoleBuffer: string[] = []
    const CONSOLE_BUFFER_SIZE = 1_000

    function logToConsole(...messages: string[]) {
    consoleBuffer.push(...(messages.flatMap(msg => msg.split('\n'))))

    if (consoleBuffer.length > CONSOLE_BUFFER_SIZE) {
    consoleBuffer.splice(0, CONSOLE_BUFFER_SIZE - consoleBuffer.length)
    }

    void renderConsole()
    void renderPrompt()
    }

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

    // Render console output
    for (let dy = 0; dy < consoleRows; dy++) {
    rlOut.cursorTo(0, ROOM_DIM + 1 + dy)
    rlOut.clearLine(0)
    await rlOut.commit()

    if (dy < consoleBuffer.length) {
    output.write(consoleBuffer[dy].substring(0, output.columns))
    }
    }
    }

    /** Render the input prompt */
    async function renderPrompt() {
    rlOut.cursorTo(0, output.rows - 1)
    rlOut.clearLine(0)
    await rlOut.commit()
    rlInterface.prompt(true)
    }

    api.socket.on(ScreepsSocketClient.CONNECTED, () => {
    console.info('Connected to WebSocket API')
    })
    api.socket.on(ScreepsSocketClient.AUTH, (event: ServerAuthEvent) => {
    if (event.data.status === ServerAuthStatuses.Failed) {
    console.error('WebSocket API authentication failed')
    process.exit(1)
    }
    console.info('Authenticated to WebSocket API')
    })
    api.socket.on(ScreepsSocketClient.DISCONNECTED, () => {
    console.info('Disconnected from WebSocket API')
    })
    api.socket.on(ScreepsSocketClient.ERROR, (err: unknown) => {
    console.error('WebSocket API error:', err)
    })

    console.debug('Connecting to WebSocket API')
    await api.socket.connect()

    function quit(message: string, code = 0) {
    console.log(message)
    process.exit(code)
    }

    const rlInterface = readline.createInterface({
    input,
    output,
    prompt: `${api.appConfig.defaultShard ?? ''}> `
    })

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

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

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

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

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

    // Monkeypatch console methods to display output in the console area
    function logToConsoleMonkeypatch(...args: unknown[]) {
    logToConsole('<<< ' + args.map(arg => String(arg)).join(' '))
    }

    console.log = logToConsoleMonkeypatch
    console.debug = logToConsoleMonkeypatch
    console.info = logToConsoleMonkeypatch
    console.warn = logToConsoleMonkeypatch
    console.error = logToConsoleMonkeypatch

    void render()

    /**
    * Strip HTML tags from a string to make it more readable.
    * This is not suitable for sanitizing untrusted input.
    */
    function stripTags(text: string): string {
    return text.replaceAll(/<\s*?\/?\s*?\w+?(?:[\w\s=]+?'[^>]*'?|[\w\s=]+?"[^>]*"?|[\w\s=]+?`[^>]*`?|[\w\s]+?)*>/g, '')
    }

    void api.socket.subscribeUserConsole((event: UserConsoleEvent) => {
    const { messages, error, shard } = event.data
    const shardTag = shard ? `[${shard}] ` : ''

    // Add newest console messages
    const newMessages = messages
    ? [
    ...messages.results.flatMap(msg => `< ${msg}`),
    ...messages.log.flatMap(msg => `${shardTag}${stripTags(msg)}`)
    ]
    : []
    if (error) newMessages.push(`${shardTag}${error}`)

    logToConsole(...newMessages)
    })

    void api.socket.subscribeUserCpu(async (event: UserCpuEvent) => {
    cpuData = event.data
    await renderStats()
    await renderPrompt()
    })

    // Pick an initial room to load
    const startRoomRes = await api.userWorldStartRoom()
    const startRoom = startRoomRes.room[0]
    if (startRoom) {
    await changeRoom(startRoom)
    }