This page as Markdown: https://bloxd.io/docs/how-to-interactable-npcs.md. Every page of these docs, for AI agents: https://bloxd.io/docs/llms.txt

Interactable NPCs

An interactable entity is one the player aims at and interacts with rather than attacks. Interacting fires onPlayerAltAction with the entity as the target, and the crosshair tells the player which key to press.

A shopkeeper

let shopkeeperId = null

tick = () => {
    if (shopkeeperId !== null) {
        return
    }

    if (api.getBlock(8, 4, 8) === "Unloaded") {
        // Wait for the chunk to load before placing the NPC
        return
    }

    shopkeeperId = api.attemptCreateMeshEntity("Person", {
        size: 1,
        pose: "standing",
        textures: { head: "trader_black" },
    }, "Blacksmith")

    if (shopkeeperId === null) {
        // The mesh entity limit was reached
        return
    }

    api.setPosition(shopkeeperId, 8, 5, 8)

    // interactable is what keeps the NPC pickable, so canAttack can stay off
    api.setTargetedPlayerSettingForEveryone(shopkeeperId, "canAttack", false)
    api.setTargetedPlayerSettingForEveryone(shopkeeperId, "interactable", true)
    api.setTargetedPlayerSettingForEveryone(shopkeeperId, "interactionPrompt", "Buy Gear")
}

onPlayerAltAction = (playerId, x, y, z, block, targetEId) => {
    if (targetEId === shopkeeperId) {
        // Toggle off, or holding the key would flicker the shop open and closed
        api.openShop(playerId, false)
    }
}

Players now see the interact key and Buy Gear at the crosshair whenever they look at the Blacksmith, and pressing it opens the shop. Hitting the NPC does nothing at all.

Guarding a held key

onPlayerAltAction keeps firing while the interact key is held down, roughly every 50-150ms. Reopening an already open shop does nothing, so the example above needs no guard. An interaction that grants an item, charges a currency or advances a conversation is not idempotent, and has to swallow the repeats itself:

const INTERACT_COOLDOWN_MS = 800
const lastInteractAt = {}

onPlayerAltAction = (playerId, x, y, z, block, targetEId) => {
    if (targetEId === questGiverId) {
        const now = api.now()
        if (now - (lastInteractAt[playerId] ?? 0) >= INTERACT_COOLDOWN_MS) {
            lastInteractAt[playerId] = now
            sayNextLine(playerId)
        }
    }
}

onPlayerLeave = (playerId) => {
    delete lastInteractAt[playerId]
}

800ms is long enough to swallow a held key and short enough that two deliberate presses still count twice. A quest giver fills in sayNextLine.

Logo