IoT Digital Twin with Roblox and The Things Network

📝 8 Oct 2021

Roblox is a Multiplayer Virtual World that lets us create 3D Objects and interact with them. (Free to create and play)

The Things Network is a Public Wireless Network that connects many IoT Gadgets around the world. (It’s free too)

Can we connect Roblox to The Things Network… To Monitor and Control Real-World Gadgets?

Digital Twin

Think of the possibilities…

  1. Walk around a Roblox House to monitor the temperature in our Smart Home.

    Flip the lights on and off in the Virtual House, to control the lights in our Real Home.

    (Check out this excellent article by Camden Bruce)

  2. Wander about a Roblox Farm to check on Farm Crops and Livestock in real life.

    (Yes there are Cow Sensors)

  3. Teach young learners about Internet of Things (IoT)

    How Sensors and Actuators work, and how they impact our lives.

Sounds very “Free Guy” and “Matrix”-ish, but the above is actually a well-known concept in IoT: Digital Twin.

What’s a Digital Twin?

A Digital Twin is a Virtual Object that mirrors a Real-World Object through Sensors and Actuators. (Like the pic above)

For today’s experiment we shall take this IoT Gadget: PineDio Stack BL604 RISC-V Board

PineDio Stack BL604 RISC-V Board (foreground) talking to The Things Network via RAKWireless RAK7248 LoRaWAN Gateway (background)

And turn it into a Virtual Gadget in Roblox such that…

All Roblox Scripts may be found in this repo…

(Apologies if my Roblox looks rough… This is my first time using Roblox 🙏)

Cold / Hot / Normal IoT Objects rendered in Roblox

§1 Roblox Mirrors Real Life

The pic shows what we shall accomplish with Roblox…

A Virtual Object that visualises the Live Temperature of our Real Object (PineDio Stack)

In fact we’ll show 10,000 Levels of Hotness / Coldness, thanks to a little Math. (And Linear Interpolation)

What magic makes this mirroring possible?

This mirroring of real things in Roblox is possible because…

  1. Roblox lets us write Lua Scripts that can make HTTP Requests to the internet

  2. The Things Network exposes a HTTP Service that lets us retrieve the Sensor Data (like Temperature) sent by IoT Gadgets

Connect (1) to (2) and we’ll get a Roblox Gadget that mirrors the Hot / Cold State of a Real Gadget.

Let’s talk about Roblox Lua Scripts and HTTP Requests…

(More about Roblox Lua Scripting)

(More about The Things Network)

Roblox talking to The Things Network

§2 Roblox Fetches Sensor Data

Roblox provides a HttpService API that we may call in our Lua Scripts to fetch External HTTP URLs (via GET and POST)…

Below we see HttpService in action, fetching the current latitude and longitude of International Space Station

Roblox Lua Script calls HttpService

(Source)

To fetch Sensor Data from The Things Network, we have created a getSensorData function in DigitalTwin.lua.

When we run this Roblox Script…

-- Fetch the Sensor Data from The Things Network (LoRa)
local sensorData = getSensorData()

-- Show the Temperature
if sensorData then
  print("Temperature:")
  print(sensorData.t)
else
  print("Failed to get sensor data")
end

(Source)

We should see the Temperature Sensor Data fetched from The Things Network…

Temperature: 1236

(This means 12.36 ºC, our values have been scaled up by 100 times)

Let’s study the code inside our getSensorData function.

§2.1 Define Constants

We begin by defining the constants for accessing The Things Network: DigitalTwin.lua

-- Enable type checking
--!strict

-- TODO: Change this to your Application ID for The Things Network
-- (Must have permission to Read Application Traffic)
local TTN_APPLICATION_ID = "YOUR_APPLICATION_ID"

-- TODO: Change this to your API Key for The Things Network
local TTN_API_KEY = "YOUR_API_KEY"

-- TODO: Change this to your region-specific URL for The Things Network
local TTN_URL = "https://au1.cloud.thethings.network/api/v3/as/applications/" .. TTN_APPLICATION_ID .. "/packages/storage/uplink_message?limit=1&order=-received_at"

(“..” in Lua means concatenate the strings)

Our URL for The Things Network (TTN_URL) looks like…

https://au1.cloud.thethings.network/api/v3/as/
  applications/YOUR_APPLICATION_ID/
  packages/storage/uplink_message
  ?limit=1&order=-received_at

(More about these settings in the Appendix)

Note that we enable Type Checking at the top…

-- Enable type checking
--!strict

This is super helpful for catching incorrect parameters passed to function calls.

Click View → Script Analysis to see the warnings.

(Ignore the two warnings for “Argument count mismatch”)

§2.2 Import Modules

Next we get the HttpService from Roblox…

-- Get the HttpService for making HTTP Requests
local HttpService = game:GetService("HttpService")

HTTP Requests must be enabled in Roblox…

Click Home → Game Settings → Security → Allow HTTP Requests

We import the ModuleScripts that will be called to decode our Sensor Data…

-- Load the Base64 and CBOR ModuleScripts from ServerStorage
local ServerStorage = game:GetService("ServerStorage")
local base64 = require(ServerStorage.Base64)
local cbor   = require(ServerStorage.Cbor)

We’ll talk about Base64 and CBOR in a while.

(More about Roblox ModuleScripts)

§2.3 Send HTTP Request

Our function begins by declaring the variables

-- Fetch Sensor Data from The Things Network (LoRa) as a Lua Table
local function getSensorData()
  -- HTTPS JSON Response from The Things Network
  local response = nil
  -- Lua Table parsed from JSON response
  local data = nil
  -- Message Payload from the Lua Table (encoded with Base64)
  local frmPayload = nil
  -- Message Payload after Base64 Decoding
  local payload = nil
  -- Lua Table of Sensor Data after CBOR Decoding
  local sensorData = nil

We set the API Key in the HTTP Request Header (as “Authorization”)…

  -- Set the API Key in the HTTP Request Header	
  local headers = {
    ["Authorization"] = "Bearer " .. TTN_API_KEY,
  }

(“..” in Lua means concatenate the strings)

Then we fetch the URL (via HTTP GET), passing the API Key in the headers…

  -- Wrap with pcall in case something goes wrong
  pcall(function ()

    -- Fetch the data from The Things Network, no caching
    response = HttpService:GetAsync(TTN_URL, false, headers)

(GetAsync is documented here)

What is “pcall”?

We wrap our code with “pcall” to catch any errors returned by the HTTP Fetching.

(Also for catching Decoding Errors)

If any error occurs, execution resumes after the “pcall” block.

And we’ll check for errors then.

(pcall is documented here)

JSON HTTP Response decoded as Lua Table

§2.4 Decode HTTP Response

Now we decode the HTTP Response (JSON format) from The Things Network.

First we parse the JSON returned by The Things Network…

    -- Decode the JSON response into a Lua Table
    data = HttpService:JSONDecode(response)

(JSONDecode is documented here)

This returns a Lua Table that contains the JSON Fields.

(See the pic above)

As shown in the pic, we need to extract the Encoded Sensor Data from the field: result → uplink_message → frm_payload

    -- Get the Message Payload. If missing, pcall will catch the error.
    frmPayload = data.result.uplink_message.frm_payload

frmPayload contains the Sensor Data encoded with Base64 and CBOR.

(Looks like gibberish: “omF0GQTUYWwZCSs=”)

We call the Base64 and CBOR ModuleScripts to decode the Sensor Data

    -- Base64 Decode the Message Payload
    payload = base64.decode(frmPayload)

    -- Decode the CBOR Map to get Sensor Data
    sensorData = cbor.decode(payload)

  -- End of pcall block
  end)

(More about Base64 and CBOR in a while)

sensorData now contains meaningful Sensor Data in a Lua Table…

{
  ["l"] = 2347,
  ["t"] = 1236
}

Above are the values recorded by our Light Sensor and Temperature Sensor, scaled up by 100 times.

Note that our “pcall” block ends here. So we check the errors next.

§2.5 Check Errors

We’re at the spot after the “pcall” block.

We check for errors that could have occurred inside the “pcall” block…

  -- Show the error
  if response == nil then
    print("Error returned by The Things Network")
  elseif data == nil then
    print("Failed to parse JSON response from The Things Network")
  elseif frmPayload == nil then
    print("Missing message payload")
  elseif payload == nil then
    print("Base64 decoding failed")
  elseif sensorData == nil then
    print("CBOR decoding failed")
  end

This code checks for HTTP Request Errors and Decoding Errors.

§2.6 Return Sensor Data

Finally we return the Sensor Data (as a Lua Table) to the caller…

  -- sensorData will be nil if our request failed or JSON failed to parse
  -- or Message Payload missing or Base64 / CBOR decoding failed
  return sensorData
end

Our Sensor Data is returned as nil in case of error.

And that’s how our Roblox Script fetches Sensor Data from The Things Network!

Roblox Fetches Sensor Data

§3 Roblox Mirroring In Action

Before heading deeper into our Roblox Scripts, let’s watch our Virtual Gadget in action!

  1. Download and install Roblox Studio

    “Install Roblox Studio”

  2. In Roblox Studio, click New → Classic Baseplate

  3. We need to enable HTTP Requests…

    At the top bar, click Home → Game Settings → Security → Allow HTTP Requests

Create Part in Roblox Studio

§3.1 Create Part and Script

  1. At Explorer → Workspace (at right)…

    Click (+) and create a Part

    (See pic above)

  2. Under our Part

    Click (+) and create a Script

    (See pic below)

  3. Copy and paste the contents of this link into the script…

Create Script in Roblox Studio

§3.2 Edit Settings

  1. If we have an IoT Gadget connected to The Things Network:

    Edit these settings…

    -- TODO: Change this to your Application ID for The Things Network
    -- (Must have permission to Read Application Traffic)
    local TTN_APPLICATION_ID = "YOUR_APPLICATION_ID"
    
    -- TODO: Change this to your API Key for The Things Network
    local TTN_API_KEY = "YOUR_API_KEY"
    
    -- TODO: Change this to your region-specific URL for The Things Network
    local TTN_URL = "https://au1.cloud.thethings.network/api/v3/as/applications/" .. TTN_APPLICATION_ID .. "/packages/storage/uplink_message?limit=1&order=-received_at"

    (More about this in the Appendix)

  2. If we don’t have an IoT Gadget:

    Leave the above settings as is.

    The script will run in Demo Mode, simulating a real gadget.

Create Base64 ModuleScript in Roblox Studio

§3.3 Create ModuleScripts

  1. At Explorer → ServerStorage (at right)…

    Click (+) and create two ModuleScripts:

    Base64 and Cbor

    (See pic above)

  2. Copy and paste the the contents of these links into the ModuleScripts…

    (Yep they need to be ModuleScripts. Normal Scripts won’t work)

Create Cbor ModuleScript in Roblox Studio

§3.4 Watch It Run

At the top bar, click Home → Play

(Or press F5)

Roblox renders our Virtual Gadget in its Hot / Cold State!

Watch the Demo Video on YouTube

Cold / Normal / Hot IoT Objects rendered in Roblox

§4 Decode Base64 and CBOR in Roblox

Why do we need CBOR Decoding?

Normally IoT Gadgets will transmit Sensor Data in JSON like so….

{ 
  "t": 1236, 
  "l": 2347 
}

That’s 19 bytes of JSON for Temperature Sensor and Light Sensor Data.

But this won’t fit into the Maximum Message Size for The Things Network: 12 bytes.

(Assuming 10 messages per hour)

Instead we compress the Sensor Data into Concise Binary Object Representation (CBOR) Format.

(CBOR works like a compact, binary form of JSON)

And we need only 11 bytes of CBOR!

a2 61 74 19 04 d4 61 6c 19 09 2b

(More about CBOR)

Encoding Sensor Data with CBOR on BL602

(Source)

What about the Base64 Decoding?

Our IoT Gadget transmits Sensor Data to The Things Network in Binary Format (CBOR).

But our Roblox script fetches the Sensor Data in JSON Format, which can’t embed Binary Data.

Hence our Binary Data is converted to Text Format with Base64 Encoding, when fetched by Roblox.

Our Sensor Data encoded with CBOR

a2 61 74 19 04 d4 61 6c 19 09 2b

Becomes this Text String when encoded with Base64

omF0GQTUYWwZCSs=

This explains why we need two stages of decoding: Base64 followed by CBOR.

Create Base64 ModuleScript in Roblox Studio

§4.1 Base64 and CBOR ModuleScripts

How do we decode Base64 and CBOR in Roblox?

We call these two ModuleScripts in ServerStorage

Like so…

-- Load the Base64 and CBOR ModuleScripts from ServerStorage
local ServerStorage = game:GetService("ServerStorage")
local base64 = require(ServerStorage.Base64)
local cbor   = require(ServerStorage.Cbor)

-- Base64 Decode the Message Payload
payload = base64.decode('omF0GQTUYWwZCSs=')
print("payload:")
print(payload)

-- Decode the CBOR Map
sensorData = cbor.decode(payload)
print("sensorData:")
print(sensorData)

(Source)

We should see…

payload:
�at�al

sensorData:
{
  ["l"] = 2347,
  ["t"] = 1236
}

Did we create the ModuleScripts from scratch?

Nope, they were copied from existing Lua Libraries

Was it difficult to port the Lua Libraries into Roblox?

Not at all! We changed only one line of code in Base64.lua from…

local extract = _G.bit32 and _G.bit32.extract

To…

local extract = bit32 and bit32.extract

And the ModuleScripts worked perfectly!

Porting Lua Libraries into Roblox

§5 Render Temperature With Roblox Particle Emitter

How did we render the Temperature of our Roblox Gadget?

(The green fireflies thingy?)

We rendered the Temperature with a Roblox Particle Emitter.

This is how we render a Particle Emitter in our Roblox Script…

-- Create a Particle Emitter for Normal Temperature
local emitter = createParticleEmitter()

(Source)

The code above renders our Roblox Gadget with Normal Temperature.

(Yep Shrek and his green fireflies)

createParticleEmitter is defined in DigitalTwin.lua

-- Create the Particle Emitter for Normal Temperature
-- Based on https://developer.roblox.com/en-us/api-reference/class/ParticleEmitter
local function createParticleEmitter()

  -- Create an instance of Particle Emitter and enable it
  local emitter = Instance.new("ParticleEmitter")
  emitter.Enabled = true 

We begin by creating an Instance of Particle Emitter.

Next we set the rate of particles emitted and their lifetime…

  -- Number of particles = Rate * Lifetime
  emitter.Rate = 20 -- Particles per second
  emitter.Lifetime = NumberRange.new(5, 10) -- How long the particles should be alive (min, max)

(Why these Magic Numbers? We’ll learn later)

We set the texture of the particles to a Star Sparkle image…

  -- Visual properties
  -- Texture for the particles: "star sparkle particle" by @Vupatu
  -- https://www.roblox.com/library/6490035152/star-sparkle-particle
  emitter.Texture = "rbxassetid://6490035152"

(Somehow I couldn’t set the texture to “rbxasset:textures/particles/sparkles_main.dds”. Only “rbxassetid” works)

Our particles can change color, but we’ll stick to green (R=0.3, G=0.6, B=0.0)

  -- For Color, build a ColorSequence using ColorSequenceKeypoint
  local colorKeypoints = {
    -- API: ColorSequenceKeypoint.new(time, color)
    ColorSequenceKeypoint.new( 0.0, Color3.new(0.3, 0.6, 0.0)),  -- At time=0: Green
    ColorSequenceKeypoint.new( 1.0, Color3.new(0.3, 0.6, 0.0))   -- At time=1: Green
  }
  emitter.Color = ColorSequence.new(colorKeypoints)

This Color Sequence says that from start (time=0) to end (time=1), the particles stay green.

We won’t vary the particle transparency either…

  -- For Transparency, build a NumberSequence using NumberSequenceKeypoint
  local numberKeypoints = {
    -- API: NumberSequenceKeypoint.new(time, size, envelop)
    NumberSequenceKeypoint.new( 0.0, 0.0);    -- At time=0, fully opaque
    NumberSequenceKeypoint.new( 1.0, 0.0);    -- At time=1, fully opaque
  }
  emitter.Transparency = NumberSequence.new(numberKeypoints)

From start to end, our particles are fully opaque.

We set the Light Emission and Influence

  -- Light Emission and Influence
  emitter.LightEmission = 0 -- If 1: When particles overlap, multiply their color to be brighter
  emitter.LightInfluence = 1 -- If 0: Don't be affected by world lighting

We define the speed and spread of our particles…

  -- Speed properties
  emitter.EmissionDirection = Enum.NormalId.Top -- Emit towards top
  emitter.Speed = NumberRange.new(5.0, 5.0) -- Speed
  emitter.Drag = 10.0 -- Apply drag to particle motion
  emitter.VelocityInheritance = 0 -- Don't inherit parent velocity
  emitter.Acceleration = Vector3.new(0.0, 0.0, 0.0)
  emitter.LockedToPart = false -- Don't lock the particles to the parent 
  emitter.SpreadAngle = Vector2.new(50.0, 50.0) -- Spread angle on X and Y

We set the size and rotation of our particles…

  -- Simulation properties
  local numberKeypoints2 = {
    NumberSequenceKeypoint.new(0.0, 0.2);  -- Size at time=0
    NumberSequenceKeypoint.new(1.0, 0.2);  -- Size at time=1
  }
  emitter.Size = NumberSequence.new(numberKeypoints2)
  emitter.ZOffset = 0.0 -- Render in front or behind the actual position
  emitter.Rotation = NumberRange.new(0.0, 0.0) -- Rotation
  emitter.RotSpeed = NumberRange.new(0.0) -- Do not rotate during simulation

Finally we add the emitter to our Roblox Part…

  -- Add the emitter to our Part
  emitter.Parent = script.Parent
  return emitter
end

And our Roblox Gadget starts emitting green particles to represent Normal Temperature!

(Centre one in the pic below)

Cold / Normal / Hot IoT Objects rendered in Roblox

§5.1 Magic Numbers

All the Magic Numbers above… Where did they come from?

I created three Particle Emitters for the Cold, Normal and Hot Temperatures…

Particle Emitters for Cold / Normal / Hot Temperatures

I tweaked them till they looked OK. Then I dumped the settings of the Particle Emitters like so…

-- Dump the 3 Particle Emitters: Cold, Normal, Hot
print("COLD Particle Emitter (t=0)")
dumpParticleEmitter(script.Parent.Cold)

print("NORMAL Particle Emitter (t=5000)")
dumpParticleEmitter(script.Parent.Normal)

print("HOT Particle Emitter (t=10000)")
dumpParticleEmitter(script.Parent.Hot)

(Source)

(dumpParticleEmitter is defined in DigitalTwin.lua)

The Particle Emitter settings look like…

NORMAL Particle Emitter (t=5000)
  Acceleration: 0, 0, 0
  Color: 
    0 0.3 0.6 0 0 
    1 0.3 0.6 0 0 
  Drag: 10
  ...

(See the complete settings)

These are the Magic Numbers that we plugged into our createParticleEmitter function.

Why did we create the Particle Emitter in Roblox Script?

Why not reuse the Particle Emitters that we have created manually?

That’s because we want to render 10,000 Levels of Hotness / Coldness.

Our Roblox Script will tweak the Particle Emitter at runtime to render the Live Temperature.

Read on to learn how we do this with Linear Interpolation.

Cold / Normal / Hot IoT Objects rendered in Roblox

§5.2 Interpolate the Particle Emitter

Previously we have dumped the settings for our Hot / Normal / Cold Particle Emitters…

COLD Particle Emitter (t=0)
  Drag:  5
  Speed: 0 0 
  Color: (time, red, green, blue)
    0 0.3 1.0 1.0 
    1 0.3 1.0 1.0 
    ...

NORMAL Particle Emitter (t=5000)
  Drag:  10
  Speed: 5 5 
  Color: (time, red, green, blue)
    0 0.3 0.6 0.0 
    1 0.3 0.6 0.0 
    ...

HOT Particle Emitter (t=10000)
  Drag:  0
  Speed: 1 1 
  Color: (time, red, green, blue)
    0 1.0 0.3 0.0 
    1 1.0 0.3 0.0 
    ...

(See the complete settings)

The three emitters represent the Min / Mid / Max Temperatures

How shall we interpolate the three emitters… To render 10,000 Levels of Hotness / Coldness?

Based on the values above, we derive the following values that shall be interpolated into 10,000 levels as we transition between Cold / Normal / Hot…

Drag:
  COLD:   5
  NORMAL: 10
  HOT:    0

Speed: 
  COLD:   0 0 
  NORMAL: 5 5 
  HOT:    1 1 

Color: (time, red, green, blue)
  COLD:
    0 0.3 1.0 1.0 
    1 0.3 1.0 1.0 
  NORMAL:
    0 0.3 0.6 0.0 
    1 0.3 0.6 0.0 
  HOT:
    0 1.0 0.3 0.0 
    1 1.0 0.3 0.0 
    ...

(See the complete interpolation)

Let’s plug the derived values into our Roblox Script.

Interpolating the Particle Emitter

§5.3 Update the Particle Emitter

We take the values derived above and plug them into our updateParticleEmitter function from DigitalTwin.lua

-- Update the Particle Emitter based on the Temperature t.
-- t ranges from T_MIN (0) to T_MAX (10,000).
local function updateParticleEmitter(emitter, t)

  -- Interpolate Drag:
  -- COLD:   5
  -- NORMAL: 10
  -- HOT:    0
  emitter.Drag = lin(t, 5.0, 10.0, 0.0)

  -- Interpolate Speed: 
  -- COLD:   0 0
  -- NORMAL: 5 5
  -- HOT:    1 1
  local speed = lin(t, 0.0, 5.0, 1.0)
  emitter.Speed = NumberRange.new(speed, speed) -- Speed

lin is our helper function that computes Linear Interpolation.

(More about this in the next section)

In the code above we interpolate the Drag and Speed of our Particle Emitter, based on the Temperature (t).

For the color of our Particle Emitter, we compute the interpolated color

  -- Interpolate Color: (Red, Green, Blue)
  -- COLD:   0.3, 1.0, 1.0
  -- NORMAL: 0.3, 0.6, 0.0
  -- HOT:    1.0, 0.3, 0.0
  local color = Color3.new(
    lin(t, 0.3, 0.3, 1.0),  -- Red
    lin(t, 1.0, 0.6, 0.3),  -- Green
    lin(t, 1.0, 0.0, 0.0)   -- Blue
  )

Then we update the Color Sequence based on the interpolated color…

  local colorKeypoints = {
    -- API: ColorSequenceKeypoint.new(time, color)
    ColorSequenceKeypoint.new(0.0, color),  -- At time=0
    ColorSequenceKeypoint.new(1.0, color)   -- At time=1
  }
  emitter.Color = ColorSequence.new(colorKeypoints)

(See the rest of the function here)

And we’re done! To render the Live Temperature, we call updateParticleEmitter like so…

-- Create a Particle Emitter for Normal Temperature
local emitter = createParticleEmitter()

-- Update the emitter to render Temperature=1234
updateParticleEmitter(emitter, 1234)

(Source)

Here’s how our Interpolating Particle Emitter looks…

Updating the Particle Emitter

§5.4 Linear Interpolation

How does the lin function compute Linear Interpolation?

Earlier we saw this…

-- Interpolate Drag:
-- COLD:   5
-- NORMAL: 10
-- HOT:    0
emitter.Drag = lin(t, 5.0, 10.0, 0.0)

This code interpolates the Drag of our Particle Emitter based on the Temperature (t).

The values passed to lin

5.0, 10.0, 0.0

Correspond to the Drag values for Min / Mid / Max Temperatures.

The Min / Mid / Max Temperatures are defined here: DigitalTwin.lua

-- Minimum, Maximum and Mid values for Temperature (t) that will be interpolated
local T_MIN = 0
local T_MAX = 10000
local T_MID = (T_MIN + T_MAX) / 2

We compute the Linear Interpolation by drawing lines between the Min, Mid and Max values…

Computing the Linear Interpolation

Note that we compute the Linear Interpolation a little differently depending on whether the Temperature is less or greater than 5,000 (T_MID)…

Computing the Linear Interpolation

Below is our lin function that handles both cases: DigitalTwin.lua

-- Linear Interpolate the value of y, given that
-- (1) x ranges from T_MIN to T_MAX
-- (2) When x=T_MIN, y=yMin
-- (3) When x=T_MID, y=yMid
-- (4) When x=T_MAX, y=yMax
local function lin(x: number, yMin: number, yMid: number, yMax: number) : number
  local y: number
  if x < T_MID then
    -- Interpolate between T_MIN and T_MID
    y = yMin + (yMid - yMin) * (x - T_MIN) / (T_MID - T_MIN)
  else
    -- Interpolate between T_MID and T_MAX
    y = yMid + (yMax - yMid) * (x - T_MID) / (T_MAX - T_MID)
  end	
  -- Force y to be between yMin and yMax
  if y < math.min(yMin, yMid, yMax) then
    y = math.min(yMin, yMid, yMax)
  end
  if y > math.max(yMin, yMid, yMax) then
    y = math.max(yMin, yMid, yMax)
  end
  return y
end

UPDATE: There’s another way to do Linear Interpolation in Roblox: TweenService

PineDio Stack BL604 RISC-V Board (foreground) talking to The Things Network via RAKWireless RAK7248 LoRaWAN Gateway (background)

§6 Digital Twin Demo

As promised, here’s the Real-Life Demo of our Roblox Digital Twin featuring PineDio Stack! (Pic above)

We follow the instructions below to run the LoRaWAN Firmware on PineDio Stack…

Digital Twin 55.55 ⁰C

Our demo setup…

The temperature is now 55.55 ⁰C. Let’s set the PineDio Stack temperature to 99.99 ⁰C

las_app_tx_cbor 2 0 9999 0

(las_app_tx_cbor is explained here)

Our Roblox Gadget receives the high temperature and bursts into flames!

Digital Twin at 99.99 ⁰C

Let’s turn down PineDio Stack to 77.77 ⁰C

las_app_tx_cbor 2 0 7777 0

Our Roblox Gadget receives the updated temperature over The Things Network. And cools down a little.

Digital Twin at 77.77 ⁰C

We cool PineDio Stack down to 33.33 ⁰C

las_app_tx_cbor 2 0 3333 0

Our Roblox Gadget turns blue.

Digital Twin at 33.33 ⁰C

We start to freeze PineDio Stack at 11.11 ⁰C

las_app_tx_cbor 2 0 1111 0

Our Roblox Gadget turns into ice!

Digital Twin at 11.11 ⁰C

§6.1 Demo Code

Below is the source code for the demo that we’ve seen. It calls all the functions that we’ve covered in this article: DigitalTwin.lua

-- Main Function. Fetch and render the Sensor Data from The Things Network every 5 seconds.
-- If fetch failed, show Demo Mode.
local function main()	
  -- Create a Particle Emitter for Normal Temperature
  local emitter = createParticleEmitter()
  
  -- Loop forever fetching and rendering Sensor Data from The Things Network
  while true do
    -- Lua Table that will contain Sensor Data from The Things Network	
    local sensorData = nil

    -- Temperature from The Things Network. Ranges from 0 to 10,000.
    local t = nil

    -- If API Key for The Things Network is defined...
    if TTN_API_KEY ~= "YOUR_API_KEY" then
      -- Fetch the Sensor Data from The Things Network
      sensorData = getSensorData()	

      -- Get the Temperature if it exists
      if sensorData then
        t = sensorData.t
      end
    end

    -- If Temperature was successfully fetched from The Things Network...
    if t then
      -- Render the Temperature with our Particle Emitter
      print("t:", t)
      updateParticleEmitter(emitter, t)
    else
      -- Else render our Particle Emitter in Demo Mode
      print("Failed to get sensor data. Enter Demo Mode.")
      demoMode(emitter)
    end
    
    -- Sleep 5 seconds so we don't overwhelm The Things Network
    wait(5)		
  end
end

-- Start the Main Function
main()

(demoMode is explained here)

That’s all for our demo today. Would be so fun if someday Roblox could overlay Real-World Objects through Augmented Reality… And show us Sensor Data in real time!

Digital Twin with Augmented Reality

§7 What’s Next

I’m new to Roblox, but I had fun connecting things in the real world to Roblox. I hope you enjoyed it too!

In the next article we shall head back to PineDio Stack coding, as we read and transmit PineDio Stack’s Internal Temperature Sensor to The Things Network.

Many Thanks to my GitHub Sponsors for supporting my work! This article wouldn’t have been possible without your support.

Got a question, comment or suggestion? Create an Issue or submit a Pull Request here…

lupyuen.github.io/src/roblox.md

§8 Notes

  1. This article is the expanded version of this Twitter Thread

  2. Can Roblox control IoT Gadgets, like flipping the lights in our Smart Home on and off?

    Yes this Remote Actuation is technically feasible, because The Things Network exposes a HTTP POST API for Roblox to push Downlink Messages to IoT Gadgets…

    Our IoT Gadget would need to handle Downlink Messages and actuate accordingly. (Like switch the light on / off)

  3. We may ignore these two Type Checking Warnings

    -- Base64 Decode the Message Payload
    payload = base64.decode(frmPayload)
    
    W000: (53,27) Argument count mismatch. Function expects 3 arguments, but only 1 is specified
    -- Decode the CBOR Map to get Sensor Data
    sensorData = cbor.decode(payload)
    
    W000: (56,28) Argument count mismatch. Function expects 2 arguments, but only 1 is specified

    That’s because the imported ModuleScripts (Base64 and Cbor) don’t support Type Checking.

  4. Is our Roblox Script running on the Roblox Server or Client?

    Our script runs on the Roblox Server. So the API Key is not visible by players.

    Does the Roblox Server run our script all the time?

    When a player joins the game, Roblox starts a server to run our script. (Up to 50 players per server)

    When all players leave the game, Roblox shuts down the server.

  5. If we don’t wish to decode CBOR in the Roblox Script, there’s another solution: Decode CBOR in The Things Network with a Payload Formatter

    “CBOR Payload Formatter for The Things Network”

§9 Appendix: Install Roblox Studio

Here are the steps to download and install Roblox Studio for macOS and Windows

  1. Sign up for a free account at roblox.com

  2. Log in to roblox.com

  3. Click “Create” at the top bar

  4. Click “Start Creating”

  5. The Roblox Studio Installer will be downloaded.

    Click the Installer to install Roblox Studio.

  6. For macOS: If the Installer (or upgrade) fails…

    Reboot macOS.

    Delete Roblox Studio in the Applications Folder.

    Reinstall Roblox Studio.

    (That’s how I fixed Roblox Studio on macOS)

To install Roblox Studio on Linux, see this…

If we’re in China, Roblox works a little differently. See this…

Remember to log in when browsing the Roblox Developer Forum.

This lets us “level up” quicker to receive posting privileges. (See this)

(We need roughly 3 hours of “Read Time” to get posting privilege)

Roblox Developer Forum

§10 Appendix: Particle Emitter Settings

During development, we created three Particle Emitters

Cold / Normal / Hot Particle Emitters

  1. Cold Particle Emitter (t=0)

    Acceleration: 0, 0, 0
    Color: 0 0.333333 1 1 0 1 0.333333 1 1 0 
    Drag: 5
    EmissionDirection: Enum.NormalId.Top
    Lifetime: 5 10 
    LightEmission: 1
    LightInfluence: 1
    Orientation: Enum.ParticleOrientation.FacingCamera
    Rate: 20
    Rotation: 0 180 
    RotSpeed: -170 -170 
    Size: 0 1 0 1 1 0 
    Speed: 0 0 
    SpreadAngle: 10, 10
    Texture: rbxasset:textures/particles/sparkles_main.dds
    TimeScale: 1
    Transparency: 0 0 0 1 0 0 
    VelocityInheritance: 0
    ZOffset: 0
  2. Normal Particle Emitter (t=5000)

    Acceleration: 0, 0, 0
    Color: 0 0.333333 0.666667 0 0 1 0.333333 0.666667 0 0 
    Drag: 10
    EmissionDirection: Enum.NormalId.Top
    Lifetime: 5 10 
    LightEmission: 0
    LightInfluence: 1
    Orientation: Enum.ParticleOrientation.FacingCamera
    Rate: 20
    Rotation: 0 0 
    RotSpeed: 0 0 
    Size: 0 0.2 0 1 0.2 0 
    Speed: 5 5 
    SpreadAngle: 50, 50
    Texture: rbxasset:textures/particles/sparkles_main.dds
    TimeScale: 1
    Transparency: 0 0 0 1 0 0 
    VelocityInheritance: 0
    ZOffset: 0
  3. Hot Particle Emitter (t=10000)

    Acceleration: 0, 0, 0
    Color: 0 1 0.333333 0 0 1 1 0.333333 0 0 
    Drag: 0
    EmissionDirection: Enum.NormalId.Top
    Lifetime: 5 10 
    LightEmission: 0
    LightInfluence: 0
    Orientation: Enum.ParticleOrientation.FacingCamera
    Rate: 20
    Rotation: 0 0 
    RotSpeed: 0 0 
    Size: 0 0.4 0 1 0.4 0 
    Speed: 1 1 
    SpreadAngle: 50, 50
    Texture: rbxasset:textures/particles/sparkles_main.dds
    TimeScale: 1
    Transparency: 0 0 0 1 0 0 
    VelocityInheritance: 0
    ZOffset: 0

(The settings were dumped with the dumpParticleEmitter function in DigitalTwin.lua)

To render the Temperature in 10,000 levels (from t=0 to t=10000), we performed Linear Interpolation on the three Particle Emitters.

By matching the above settings (row by row), we derive the emitter settings that will be interpolated

Color:
  COLD:
    0 0.333333 1 1 0 
    1 0.333333 1 1 0 
  NORMAL:
    0 0.333333 0.666667 0 0 
    1 0.333333 0.666667 0 0 
  HOT:
    0 1 0.333333 0 0 
    1 1 0.333333 0 0 

Drag:
  COLD:   5
  NORMAL: 10
  HOT:    0

LightEmission: 
  COLD:   1
  NORMAL: 0
  HOT:    0

LightInfluence: 
  COLD:   1
  NORMAL: 1
  HOT:    0

Rotation: 
  COLD:   0 180 
  NORMAL: 0 0 
  HOT:    0 0 

RotSpeed: 
  COLD:   -170 -170 
  NORMAL: 0    0 
  HOT:    0    0 

Size: 
  COLD:   0 1   0 1 1   0 
  NORMAL: 0 0.2 0 1 0.2 0 
  HOT:    0 0.4 0 1 0.4 0 
  
Speed: 
  COLD:   0 0 
  NORMAL: 5 5 
  HOT:    1 1 

SpreadAngle: 
  COLD:   10, 10
  NORMAL: 50, 50
  HOT:    50, 50

Interpolating the Particle Emitter

To create a Particle Emitter for Normal Temperature, we call createParticleEmitter in DigitalTwin.lua

-- Create a Particle Emitter for Normal Temperature
local emitter = createParticleEmitter()

Then to interpolate the Particle Emitter for High / Mid / Low Temperatures, we call updateParticleEmitter in DigitalTwin.lua

(We’ve seen createParticleEmitter and updateParticleEmitter earlier)

Here’s how our demoMode function calls updateParticleEmitter to render the Temperature in Demo Mode. (High to low, then back to high)

From DigitalTwin.lua:

-- Demo Mode if we don't have an IoT Device connected to The Things Network.
-- Gradually update our Particle Emitter for Temperature=10,000 to 0 and back to 10,000.
local function demoMode(emitter)

  -- Gradually update the emitter for Temperature=10,000 to 0
  for t = T_MAX, T_MIN, -600 do
    print("t:", t)
    updateParticleEmitter(emitter, t)
    wait(4)
  end
  
  -- Gradually update the emitter for Temperature=0 to 10,000
  for t = T_MIN, T_MAX, 600 do
    print("t:", t)
    updateParticleEmitter(emitter, t)
    wait(4)
  end
end

Our Interpolating Particle Emitter in Demo Mode looks like this…

Note that rbxasset won’t work for setting the Texture…

-- This doesn't work
emitter.Texture = "rbxasset:textures/particles/sparkles_main.dds"

But rbxassetid works OK…

-- Texture for the particles: "star sparkle particle" by @Vupatu
-- https://www.roblox.com/library/6490035152/star-sparkle-particle
emitter.Texture = "rbxassetid://6490035152"

§11 Appendix: The Things Network Settings

Earlier we saw these settings for The Things Network in DigitalTwin.lua

-- TODO: Change this to your Application ID for The Things Network
-- (Must have permission to Read Application Traffic)
local TTN_APPLICATION_ID = "YOUR_APPLICATION_ID"

-- TODO: Change this to your API Key for The Things Network
local TTN_API_KEY = "YOUR_API_KEY"

-- TODO: Change this to your region-specific URL for The Things Network
local TTN_URL = "https://au1.cloud.thethings.network/api/v3/as/applications/" .. TTN_APPLICATION_ID .. "/packages/storage/uplink_message?limit=1&order=-received_at"

This chapter explains the steps for getting the settings from The Things Network.

We assume that we have created an Application and Device in The Things Network…

To get the TTN_APPLICATION_ID

  1. Log on to The Things Network

  2. Click Menu → Console

    Select our region: Europe, North America or Australia.

  3. Copy this setting…

    (Your Region) → Applications → (Your Application) → Application ID

  4. Paste it here…

    -- TODO: Change this to your Application ID for The Things Network
    -- (Must have permission to Read Application Traffic)
    local TTN_APPLICATION_ID = "YOUR_APPLICATION_ID"

§11.1 Storage Integration

For Roblox to fetch Sensor Data from The Things Network, we shall enable Storage Integration

When Storage Integration is enabled, The Things Network will save the Uplink Messages transmitted by our devices.

(Saved messages will disappear after 2 or 3 days)

To enable Storage Integration, click…

(Your Application) → Integrations → Storage Integration → Activate Storage Integration

The Things Network Storage Integration

We’ll see the Region-Specific URL for retrieving data…

https://au1.cloud.thethings.network/api/v3/as/
  applications/YOUR_APPLICATION_ID/
  packages/storage/uplink_message

The first part of the URL…

au1.cloud.thethings.network

Depends on the region we’re using: Europe, North America or Australia.

Copy the first part of the URL and paste into the first part of TTN_URL

-- TODO: Change this to your region-specific URL for The Things Network
local TTN_URL = "https://au1.cloud.thethings.network/api/v3/as/applications/" .. TTN_APPLICATION_ID .. "/packages/storage/uplink_message?limit=1&order=-received_at"

(“..” in Lua means concatenate the strings)

Our full URL for The Things Network (TTN_URL) looks like…

https://au1.cloud.thethings.network/api/v3/as/
  applications/YOUR_APPLICATION_ID/
  packages/storage/uplink_message
  ?limit=1&order=-received_at

Note that we’re fetching the Latest Uplink Message from The Things Network…

?limit=1&order=-received_at

More about this in the next chapter.

§11.2 API Key

Roblox needs an API Key to access the stored Uplink Messages from The Things Network.

To create an API Key, click…

(Your Application) → API Keys → Add API Key

The Things Network API Key

Click “Grant Individual Rights”

Click “Read application traffic (uplink and downlink)”

Click “Create API Key”

Copy the API Key and paste here…

-- TODO: Change this to your API Key for The Things Network
local TTN_API_KEY = "YOUR_API_KEY"

§11.3 Security

If we publish our game for the public to join, players may see these (potentially sensitive) details in Roblox’s Server-Side Network Log

(API Key is not visible fortunately)

So be careful when publishing our game for public access.

Roblox’s Server-Side Network Log

§12 Appendix: Fetch Sensor Data from The Things Network

The Things Network exposes a HTTP GET API to fetch the Uplink Messages transmitted by our IoT Device…

(Assuming that Storage Integration is enabled. Saved messages will disappear after 2 or 3 days)

Here’s the command to fetch the latest Uplink Message…

curl \
  -G "https://au1.cloud.thethings.network/api/v3/as/applications/$YOUR_APPLICATION_ID/packages/storage/uplink_message" \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Accept: text/event-stream" \
  -d "limit=1" \
  -d "order=-received_at"

(See the previous chapter for $YOUR_APPLICATION_ID and $YOUR_API_KEY. The first part of the URL is specific to our region: “au1.cloud.thethings.network”)

Which returns…

{
  "result": {
    "end_device_ids": {
      "device_id": "eui-YOUR_DEVICE_EUI",
      "application_ids": {
        "application_id": "luppy-application"
      },
      "dev_eui": "YOUR_DEVICE_EUI",
      "dev_addr": "YOUR_DEVICE_ADDR"
    },
    "received_at": "2021-10-02T12:10:54.594006440Z",
    "uplink_message": {
      "f_port": 2,
      "f_cnt": 3,
      "frm_payload": "omF0GQTUYWwZCSs=",
      "rx_metadata": [
        {
          "gateway_ids": {
            "gateway_id": "luppy-wisgate-rak7248",
            "eui": "YOUR_GATEWAY_EUI"
          },
          "time": "2021-10-02T13:04:34.552513Z",
          "timestamp": 3576406949,
          "rssi": -53,
          "channel_rssi": -53,
          "snr": 12.2,
          "location": {
            "latitude": 1.27125,
            "longitude": 103.80795,
            "altitude": 70,
            "source": "SOURCE_REGISTRY"
          },
          "channel_index": 4
        }
      ],
      "settings": {
        "data_rate": {
          "lora": {
            "bandwidth": 125000,
            "spreading_factor": 10
          }
        },
        "data_rate_index": 2,
        "coding_rate": "4/5",
        "frequency": "922600000",
        "timestamp": 3576406949,
        "time": "2021-10-02T13:04:34.552513Z"
      },
      "received_at": "2021-10-02T12:10:54.385972437Z",
      "consumed_airtime": "0.370688s",
      "network_ids": {
        "net_id": "000013",
        "tenant_id": "ttn",
        "cluster_id": "ttn-au1"
      }
    }
  }
}

result.uplink_message.frm_payload contains the Sensor Data that we need, encoded with Base64 and CBOR…

"frm_payload": "omF0GQTUYWwZCSs="

Our Sensor Data is encoded with Concise Binary Object Representation (CBOR) to keep the LoRa Packets small (max 12 bytes), due to the Fair Use Policy of The Things Network…

More about CBOR Encoding…