Monitor on-chain events continuously and react to blockchain state changes in your game -- live leaderboards, trade notifications, NFT minting alerts, and more.
- Completed Guide 2: Reading Blockchain Data -- you have a working
RpcProviderand can read contract state - Completed Guide 3: Accounts & Transactions -- needed for the event decoding section that executes transactions
- HttpService enabled in Game Settings
- DataStoreService enabled (for checkpoint persistence across server restarts)
When a Cairo contract emits an event, it produces a record containing:
from_address-- the contract that emitted the eventkeys-- an array of hex strings.keys[1]is the event selector (a keccak hash of the event name). Remaining keys hold indexed parameters (markedkind = "key"in the ABI).data-- an array of hex strings containing non-indexed parameters (markedkind = "data"in the ABI).block_number,block_hash,transaction_hash-- context about when and where the event occurred.
For example, an ERC-20 Transfer event has from and to as keys, and value (a u256 split into low/high) as data:
keys[1] = 0x99cd8b... (keccak of "Transfer")
keys[2] = 0xABC... (from address)
keys[3] = 0xDEF... (to address)
data[1] = 0x1000 (amount low)
data[2] = 0x0 (amount high)
Fetch a page of events matching a filter. Returns a Promise resolving to { events, continuation_token }.
--!strict
-- ServerScriptService/QueryEvents.server.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarknetLuau = require(ReplicatedStorage:WaitForChild("StarknetLuau"))
local RpcProvider = StarknetLuau.provider.RpcProvider
local Keccak = StarknetLuau.crypto.Keccak
local StarkField = StarknetLuau.crypto.StarkField
local Constants = StarknetLuau.constants
local provider = RpcProvider.new({
nodeUrl = "https://api.zan.top/public/starknet-sepolia",
})
-- Compute the Transfer event selector
local transferSelector = StarkField.toHex(Keccak.getSelectorFromName("Transfer"))
provider
:getEvents({
from_block = { block_number = 100000 },
to_block = "latest",
address = Constants.STRK_TOKEN_ADDRESS,
keys = { { transferSelector } },
chunk_size = 50,
})
:andThen(function(result)
print("Found", #result.events, "events")
for _, event in result.events do
print("Block:", event.block_number, "Tx:", event.transaction_hash)
end
-- If there are more events, result.continuation_token is non-nil
if result.continuation_token then
print("More events available -- pass continuation_token for next page")
end
end)
:catch(function(err)
warn("Failed to query events:", tostring(err))
end)| Field | Type | Description |
|---|---|---|
from_block |
{block_number = N}, {block_hash = "0x..."}, "latest", "pending" |
Start of range (inclusive) |
to_block |
same as above | End of range (inclusive) |
address |
string? |
Filter to events from this contract |
keys |
{{string}}? |
Array of key arrays. keys[1] filters on selector. |
chunk_size |
number? |
Max events per RPC response (default 100) |
continuation_token |
string? |
Pagination cursor from previous response |
The keys filter is an array of arrays. Each position filters the corresponding keys[N] of the event. Use an empty inner array {} to match any value at that position:
-- Match Transfer events FROM a specific address
keys = {
{ transferSelector }, -- keys[1] must be Transfer
{ "0xABC_FROM_ADDRESS" }, -- keys[2] must be this sender
}
-- Match Transfer events TO a specific address (any sender)
keys = {
{ transferSelector }, -- keys[1] must be Transfer
{}, -- keys[2] can be anything
{ "0xDEF_TO_ADDRESS" }, -- keys[3] must be this recipient
}When you want every matching event without manual pagination, use getAllEvents(). It handles continuation tokens internally and returns a flat array of all events:
provider
:getAllEvents({
from_block = { block_number = 100000 },
to_block = { block_number = 100500 },
address = Constants.STRK_TOKEN_ADDRESS,
keys = { { transferSelector } },
})
:andThen(function(events)
print("Total events found:", #events)
for _, event in events do
print(" Block:", event.block_number, "From:", event.keys[2])
end
end)
:catch(function(err)
warn("Failed:", tostring(err))
end)Use getAllEvents for bounded historical queries where you know the block range. For open-ended monitoring, use EventPoller (next section).
EventPoller runs a background loop that calls starknet_getEvents at a configurable interval, delivering new events to your callback. It tracks the last processed block number so each poll only fetches new events.
--!strict
-- ServerScriptService/EventListener.server.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarknetLuau = require(ReplicatedStorage:WaitForChild("StarknetLuau"))
local RpcProvider = StarknetLuau.provider.RpcProvider
local EventPoller = StarknetLuau.provider.EventPoller
local Keccak = StarknetLuau.crypto.Keccak
local StarkField = StarknetLuau.crypto.StarkField
local Constants = StarknetLuau.constants
local provider = RpcProvider.new({
nodeUrl = "https://api.zan.top/public/starknet-sepolia",
})
local transferSelector = StarkField.toHex(Keccak.getSelectorFromName("Transfer"))
local poller = EventPoller.new({
provider = provider,
filter = {
address = Constants.STRK_TOKEN_ADDRESS,
keys = { { transferSelector } },
chunk_size = 100,
},
onEvents = function(events)
for _, event in events do
print("Transfer detected in block", event.block_number)
print(" From:", event.keys[2])
print(" To:", event.keys[3])
print(" Amount (low):", event.data[1])
end
end,
onError = function(err)
warn("Poll error:", tostring(err))
end,
interval = 15, -- seconds between polls (default 10)
})
-- start() blocks until stop() is called -- run in a background thread
task.spawn(function()
poller:start()
end)
-- Stop gracefully on server shutdown
game:BindToClose(function()
poller:stop()
end)On the first call to start(), if no from_block is set in the filter, the poller fetches the current block number from the provider and begins polling from there. Each subsequent poll starts from the block after the last event it processed.
| Field | Type | Default | Description |
|---|---|---|---|
provider |
RpcProvider | required | The RPC provider to use |
filter |
EventFilter | required | Which events to fetch |
onEvents |
(events) -> () |
required | Callback with new events |
onError |
(err) -> () |
no-op | Called on poll errors |
onCheckpoint |
(blockNumber) -> () |
nil | Called after advancing block |
interval |
number | 10 | Seconds between polls |
_dataStore |
DataStore | nil | For automatic checkpoint restore |
checkpointKey |
string | "EventPoller_checkpoint" |
DataStore key name |
| Method | Returns | Description |
|---|---|---|
start() |
void | Blocking poll loop -- run in task.spawn |
stop() |
void | Signals the loop to exit after current poll |
isRunning() |
boolean | Whether the poller is actively polling |
getLastBlockNumber() |
number? | Last successfully polled block (nil if none) |
setLastBlockNumber(n) |
void | Manually set resume point before calling start() |
getCheckpointKey() |
string? | Configured DataStore key (nil if no DataStore) |
Without persistence, a server restart means the poller starts from the current block and any events emitted during downtime are missed. Checkpoints solve this.
Pass a DataStore and checkpoint key to the poller. It will automatically restore the last block number on start() and save progress via the onCheckpoint callback:
--!strict
-- ServerScriptService/PersistentPoller.server.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")
local StarknetLuau = require(ReplicatedStorage:WaitForChild("StarknetLuau"))
local RpcProvider = StarknetLuau.provider.RpcProvider
local EventPoller = StarknetLuau.provider.EventPoller
local Keccak = StarknetLuau.crypto.Keccak
local StarkField = StarknetLuau.crypto.StarkField
local Constants = StarknetLuau.constants
local provider = RpcProvider.new({
nodeUrl = "https://api.zan.top/public/starknet-sepolia",
})
local transferSelector = StarkField.toHex(Keccak.getSelectorFromName("Transfer"))
local checkpointStore = DataStoreService:GetDataStore("EventPollerCheckpoints")
local poller = EventPoller.new({
provider = provider,
filter = {
address = Constants.STRK_TOKEN_ADDRESS,
keys = { { transferSelector } },
chunk_size = 100,
},
onEvents = function(events)
for _, event in events do
-- Process each event (update game state, notify players, etc.)
print("Transfer:", event.keys[2], "->", event.keys[3])
end
end,
onError = function(err)
warn("Poll error:", tostring(err))
end,
-- Called after each poll cycle that advances the block number.
-- Persist to DataStore so we resume from here after restart.
onCheckpoint = function(blockNumber: number)
local ok, storeErr = pcall(function()
checkpointStore:SetAsync("strk_transfers_block", blockNumber)
end)
if not ok then
warn("Failed to save checkpoint:", tostring(storeErr))
end
end,
interval = 15,
-- Auto-restore: on start(), reads this key from the DataStore
-- and resumes from the stored block number
_dataStore = checkpointStore,
checkpointKey = "strk_transfers_block",
})
task.spawn(function()
poller:start()
end)
game:BindToClose(function()
poller:stop()
end)The checkpoint lifecycle:
- First start (no checkpoint): Poller fetches current block number from the provider and begins there.
- Events arrive: After processing,
onCheckpointfires with the highest block number seen. You save it to DataStore. - Server restarts: On
start(), the poller reads the DataStore via_dataStore:GetAsync(checkpointKey)and resumes from that block. - Manual recovery: Call
poller:setLastBlockNumber(n)beforestart()to override the resume point.
Raw events give you hex strings in keys and data. If you have the contract's ABI, the Contract class can decode events into named fields automatically.
After submitting a transaction, parse its receipt to extract typed event data. This example uses Account and Contract with write operations -- see Guide 3: Accounts & Transactions for account setup.
--!strict
-- ServerScriptService/ParseReceipt.server.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarknetLuau = require(ReplicatedStorage:WaitForChild("StarknetLuau"))
local RpcProvider = StarknetLuau.provider.RpcProvider
local Contract = StarknetLuau.contract.Contract
local Account = StarknetLuau.wallet.Account
local provider = RpcProvider.new({
nodeUrl = "https://api.zan.top/public/starknet-sepolia",
})
-- ABI with event definitions
local TOKEN_ABI = {
{
type = "function",
name = "transfer",
inputs = {
{ name = "recipient", type = "core::starknet::contract_address::ContractAddress" },
{ name = "amount", type = "core::integer::u256" },
},
outputs = { { name = "success", type = "core::bool" } },
state_mutability = "external",
},
{
type = "event",
name = "Transfer",
kind = "struct",
members = {
{ name = "from", type = "core::starknet::contract_address::ContractAddress", kind = "key" },
{ name = "to", type = "core::starknet::contract_address::ContractAddress", kind = "key" },
{ name = "value", type = "core::integer::u256", kind = "data" },
},
},
}
local account = Account.fromPrivateKey({
privateKey = "0x_YOUR_PRIVATE_KEY",
provider = provider,
})
local token = Contract.new({
abi = TOKEN_ABI,
address = "0x_TOKEN_CONTRACT_ADDRESS",
provider = provider,
account = account,
})
-- Execute a transfer and parse the receipt events
token
:transfer("0x_RECIPIENT", "0x1000")
:andThen(function(result)
-- Wait for the receipt
return provider:getTransactionReceipt(result.transaction_hash)
end)
:andThen(function(receipt)
-- parseEvents decodes all events from this contract in the receipt
local parsed = token:parseEvents(receipt)
for _, event in parsed.events do
print("Event:", event.name) -- "Transfer"
print(" from:", event.fields.from)
print(" to:", event.fields.to)
print(" value:", event.fields.value.low, event.fields.value.high)
end
-- Any events that failed to decode are in parsed.errors
for _, parseErr in parsed.errors do
warn("Failed to decode event:", parseErr.error)
end
end)
:catch(function(err)
warn("Transaction failed:", tostring(err))
end)parseEvents returns { events, errors }:
events-- array of{ name: string, fields: {[string]: any}, raw: Event }. Fields are decoded according to the ABI types (u256 becomes{ low, high }, addresses are hex strings, etc.).errors-- array of{ event: Event, error: string }for events that matched the contract address but failed to decode. Empty unless decoding went wrong.
Pass { strict = true } to throw on the first decode error instead of collecting them:
-- Throws immediately if any event fails to decode
local parsed = token:parseEvents(receipt, { strict = true })queryEvents combines provider:getEvents() with the contract's address:
token
:queryEvents({
from_block = { block_number = 100000 },
to_block = "latest",
chunk_size = 50,
})
:andThen(function(result)
-- result.events are raw EmittedEvent objects (not ABI-decoded)
print("Found", #result.events, "events from this contract")
end)
:catch(function(err)
warn("Query failed:", tostring(err))
end)Note: queryEvents returns raw events (hex keys/data), not ABI-decoded ones. To decode them, you'd parse them manually or combine with parseEvents on a receipt.
-- List all events defined in the ABI
local eventNames = token:getEvents()
print("Events:", table.concat(eventNames, ", ")) -- "Approval, Transfer"
-- Check if a specific event is defined
if token:hasEvent("Transfer") then
print("Contract has Transfer event")
endEvent selectors are the keccak hash of the event name, masked to 250 bits (a Starknet felt). Use Keccak.getSelectorFromName():
local Keccak = StarknetLuau.crypto.Keccak
local StarkField = StarknetLuau.crypto.StarkField
-- Compute selector and convert to hex for use in filters
local selector = StarkField.toHex(Keccak.getSelectorFromName("Transfer"))
-- selector = "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9"getSelectorFromName returns a Felt (StarkField element). Convert to hex with StarkField.toHex() for use in event filters or comparisons.
React to NFT mints in real time -- when a new token is minted on-chain, spawn the corresponding item in your game:
--!strict
-- ServerScriptService/NFTMintFeed.server.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")
local StarknetLuau = require(ReplicatedStorage:WaitForChild("StarknetLuau"))
local RpcProvider = StarknetLuau.provider.RpcProvider
local EventPoller = StarknetLuau.provider.EventPoller
local Keccak = StarknetLuau.crypto.Keccak
local StarkField = StarknetLuau.crypto.StarkField
local provider = RpcProvider.new({
nodeUrl = "https://api.zan.top/public/starknet-sepolia",
})
local NFT_CONTRACT = "0x_YOUR_NFT_CONTRACT"
local transferSelector = StarkField.toHex(Keccak.getSelectorFromName("Transfer"))
local ZERO_ADDRESS = "0x0"
-- Track player Starknet addresses (populated during onboarding -- see Guide 5: Player Onboarding)
local playerAddresses: { [string]: Player } = {} -- starknet address -> Player
local checkpointStore = DataStoreService:GetDataStore("NFTMintCheckpoints")
local poller = EventPoller.new({
provider = provider,
filter = {
address = NFT_CONTRACT,
keys = {
{ transferSelector }, -- Transfer events only
{ ZERO_ADDRESS }, -- from = 0x0 means mint (not a regular transfer)
},
chunk_size = 100,
},
onEvents = function(events)
for _, event in events do
local toAddress = event.keys[3]
local tokenIdLow = event.data[1]
-- Check if the mint recipient is a connected player
local player = playerAddresses[toAddress]
if player then
print(player.Name, "minted NFT #" .. tokenIdLow)
-- Fire a client event to spawn the item in-game
local spawnEvent = ReplicatedStorage:FindFirstChild("NFTMinted")
if spawnEvent and spawnEvent:IsA("RemoteEvent") then
spawnEvent:FireClient(player, tokenIdLow)
end
end
end
end,
onError = function(err)
warn("NFT poller error:", tostring(err))
end,
onCheckpoint = function(blockNumber: number)
pcall(function()
checkpointStore:SetAsync("nft_mint_block", blockNumber)
end)
end,
interval = 10,
_dataStore = checkpointStore,
checkpointKey = "nft_mint_block",
})
task.spawn(function()
poller:start()
end)
game:BindToClose(function()
poller:stop()
end)This pattern:
- Filters Transfer events where
from = 0x0(mints only, not regular transfers) - Looks up the recipient in a map of connected players
- Fires a RemoteEvent to the player's client to spawn the item
- Persists checkpoints so no mints are missed across restarts
Poll for score update events and maintain a server-side leaderboard that stays synchronized with the blockchain:
--!strict
-- ServerScriptService/LeaderboardSync.server.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")
local StarknetLuau = require(ReplicatedStorage:WaitForChild("StarknetLuau"))
local RpcProvider = StarknetLuau.provider.RpcProvider
local EventPoller = StarknetLuau.provider.EventPoller
local Keccak = StarknetLuau.crypto.Keccak
local StarkField = StarknetLuau.crypto.StarkField
local provider = RpcProvider.new({
nodeUrl = "https://api.zan.top/public/starknet-sepolia",
})
local GAME_CONTRACT = "0x_YOUR_GAME_CONTRACT"
local scoreSelector = StarkField.toHex(Keccak.getSelectorFromName("ScoreUpdated"))
-- In-memory leaderboard
local scores: { [string]: number } = {} -- address -> score
local checkpointStore = DataStoreService:GetDataStore("LeaderboardCheckpoints")
local poller = EventPoller.new({
provider = provider,
filter = {
address = GAME_CONTRACT,
keys = { { scoreSelector } },
chunk_size = 100,
},
onEvents = function(events)
for _, event in events do
-- Assuming ScoreUpdated has: keys[2]=player, data[1]=newScore
local player = event.keys[2]
local newScore = tonumber(event.data[1]) or 0
scores[player] = newScore
end
-- Update a Roblox leaderboard UI, fire RemoteEvents, etc.
local count = 0
for _ in scores do
count += 1
end
print("Leaderboard updated, tracking", count, "players")
end,
onError = function(err)
warn("Leaderboard poll error:", tostring(err))
end,
onCheckpoint = function(blockNumber: number)
pcall(function()
checkpointStore:SetAsync("leaderboard_block", blockNumber)
end)
end,
interval = 10,
_dataStore = checkpointStore,
checkpointKey = "leaderboard_block",
})
task.spawn(function()
poller:start()
end)
game:BindToClose(function()
poller:stop()
end)No WebSockets in Roblox. Roblox only supports outbound HTTP requests via HttpService. There is no WebSocket or server-sent events support. EventPoller uses HTTP polling, so your minimum latency equals your interval setting. For most game features, 10-15 seconds is a good balance between responsiveness and RPC rate limits.
start() blocks the thread. EventPoller:start() runs a loop that doesn't return until stop() is called. Always wrap it in task.spawn() or it will freeze your script:
-- WRONG: blocks the entire script
poller:start()
print("This never runs")
-- CORRECT: runs in background
task.spawn(function()
poller:start()
end)
print("This runs immediately")DataStore requires a published place. The _dataStore checkpoint persistence only works in published Roblox experiences. In Studio testing without publishing, DataStore calls will fail silently (the poller handles this gracefully and continues polling, but checkpoints won't persist).
Events from other contracts are silently skipped. parseEvents only decodes events where from_address matches the contract instance's address. Events from other contracts in the same transaction receipt are ignored -- they won't appear in either events or errors.
Event selectors are case-sensitive. getSelectorFromName("Transfer") and getSelectorFromName("transfer") produce different selectors. Event names in Cairo are typically PascalCase (Transfer, Approval), while function names are snake_case (balance_of). Match the exact name from the ABI.
getAllEvents is for bounded queries. It accumulates every matching event into memory. For open-ended monitoring (no fixed to_block), use EventPoller instead. Calling getAllEvents with to_block = "latest" over a large block range can return thousands of events and consume significant memory.
You now have the tools to build reactive game features driven by on-chain data. For production deployments, Guide 8: Production Configuration covers rate limiting, caching, and error handling to keep your event polling reliable under load.