Create new sensor through modding and attach it through World Editor

Hi. I’m looking into the development of a new ADAS system that requires communication between nearby vehicles, and I was looking into implementing that through a new sensor that could be attached to each vehicle. However, I’m having some trouble with how the mod structure should be, and how to add this new sensor to a car spawned on freeroam.

Hello @MwenDavo,

Based on your question about implementing a new sensor for vehicle-to-vehicle (V2V) communication, you can structure your module by following the pattern in sensor system in BeamNG. Here’s how you can proceed:

Mod Structure:

Place your module in the following path:

lua/ge/extensions/
└── YourADASSensor.lua

Key Implementation Guidelines:

  1. Sensor Configuration and Lifecycle
  • Define your sensor configuration as a Lua table, specifying properties such as update intervals, communication range, and flags for visualization/debugging.
  • Use tech_sensors.createCustomSensor()
  1. Vehicle Attachment

Attach your sensor to a spawned active (player) vehicle via the GElua console:

extensions.load('YourADASSensor')
YourADASSensor.attachSensor()

Within attachSensor(), you can get the current player vehicle using:

local vid = be:getPlayerVehicleID(0)

Then, attach your sensor to that vehicle programmatically.

Example Module (Simplified):

local M = {}
M.dependencies = { 'tech_sensors' }

local sensorConfig = {
  physicsUpdateTime = 0.1,
  commRange = 50,
  isVisualised = false
}

M.sensors = {
  adas = nil
}

local logTag = 'YourADASSensor'

M.onInit = function()
  log('I', logTag, 'ADAS sensor initialized.')
end

M.attachSensor = function()
  local vid = be:getPlayerVehicleID(0)
  if not vid then
    log('E', logTag, 'No active vehicle found.')
    return
  end
  M.sensors.adas = tech_sensors.createCustomSensor(vid, sensorConfig)
  log('I', logTag, 'Sensor attached to vehicle ID ' .. tostring(vid))
end

M.pollData = function()
  if not M.sensors.adas then
    log('W', logTag, 'Sensor not initialized.')
    return
  end
  local data = tech_sensors.getSensorReadings(M.sensors.adas)
  dump(data[0])
end

M.removeSensor = function()
  local vid = be:getPlayerVehicleID(0)
  tech_sensors.removeAllSensorsFromVehicle(vid)
  M.sensors = { adas = nil }
  log('I', logTag, 'Sensor removed from vehicle ID ' .. tostring(vid))
end

return M

Usage from GElua Console:

extensions.load('YourADASSensor')
YourADASSensor.attachSensor()
YourADASSensor.pollData()
YourADASSensor.removeSensor()

If you have any further questions or need additional support, don’t hesitate to reach out.

Best regards,
Abdulrahman Saeed
Research Software Engineer
BeamNG.tech Support Team

Hi! First of all, thanks for the reply! The explanations you gave were really useful, but now I’m facing a different problem.

After loading the sensor with extensions.load(“taps“) (TAPS is the name of the sensor) and executing taps.attachSensor(), the console is giving me the following error:

55.583|I|GELua.ui_console.exec|GELua(Sandboxed) < "taps.attachSensor()"
55.585|E|GELua.ui_console.exec|Error: [string "lua/ge/extensions/taps.lua"]:26: attempt to call field 'createCustomSensor' (a nil value)

Seems like the tech_sensors dependency isn’t being properly loaded or something related to that.

Hi @MwenDavo,

Here’s a complete step-by-step guide to creating and integrating your custom sensor TAPS (for example, a Vehicle-to-Vehicle communication sensor) using BeamNG.tech’s sensor architecture—starting from Vehicle-Lua, through Game-Enginee Lua, then into TechCore, and finally registering it in BeamNGpy. The process aligns with how the built-in GPS sensor is implemented.


Step 1: Vehicle-Lua Controller
Path: /lua/vehicle/controller/tech/TAPS.lua
This controller simulates your sensor’s logic per vehicle during the simulation loop and feeds raw or computed data to the higher layers.

Your controller file should implement functions for initialization, per-frame update, sensor signal processing (like relative position checks), and data export.

Include the following functions in your controller module:

init
update
reset
getSensorData
getLatest
setIsVisualised
incrementTimer

Step 2: Vehicle-Lua Extension
Path: /lua/vehicle/extensions/tech/TAPS.lua
This extension manages the lifecycle of your sensor within the vehicle: creating, updating, removing, performing ad-hoc reads, and dispatching outputs to the GE layer.

It should provide the following functions:

create
updateGFX
remove
adHocRequest
cacheLatestReading
getTAPSReading
getLatest
onVehicleDestroyed

This extension calls the controller using:

controller.loadControllerExternal("tech/TAPS", "TAPS" .. sensorId, data)


Step 3: Game-Engine Lua Sensor Registration
Path: /lua/ge/extensions/tech/sensors.lua
You must fully register the TAPS sensor here to make it accessible through the GE layer and polling mechanisms.

First, define a table at the top of the file to store readings:

createTAPS
removeTAPS
getTAPSReadings
updateTAPSLastReadings
updateTAPSAdHocRequest
setTAPSUpdateTime
setTAPSIsVisualised
sendTAPSRequest
collectTAPSRequest

Step 4: Connect the Sensor to TechCore
Path: /lua/ge/extensions/tech/techCore.lua
To make your sensor poll-able by BeamNGpy via the TechCore interface, you must define request-based handler functions such as :

onSerialize 
onDeserialized
handleOpenTAPS
handleGetTAPSId
handleCloseTAPS
handlePollTAPSGE
handleSendAdHocRequestTAPS
handleIsAdHocPollRequestReadyTAPS
handleCollectAdHocPollRequestTAPS
handleSetTAPSRequestedUpdateTime
handleSetTAPSIsVisualised

This connects your sensor to the central polling system inside BeamNG.tech.


Step 5: BeamNGpy Sensor API
Path: src/beamngpy/sensors/taps.py
In your BeamNGpy fork or local copy, implement a new class named TAPS. It should mirror the structure of the GPS sensor.

Once done, you’ll be able to add and use the sensor in Python scripts like this:

from beamngpy.sensors import GPS
taps_v1 = TAPS("TAPS_sensor", bng, ego_vehicle)
data_= taps_v1.poll()

Step 6: File Structure Example

lua/  
│── ge/ 
│   └── extensions/
│         └── tech/
│               └── sensors.lua          (TAPS functions registered here)  
│               └── techCore.lua         (Sensor exposed to BeamNGpy)  
│── vehicle/  
│   ├── controller/tech/TAPS.lua             (Sensor logic and computation)  
│   └── extensions/tech/TAPS.lua             (Sensor manager and dispatcher)  

BeamNGpy/src/  
└── beamngpy/  
    └── sensors/  
        └── taps.py                          (Python class to interface with TAPS)

Summary

To fully integrate TAPS with BeamNG.tech and BeamNGpy:

  • Build your Vehicle-Lua controller with sensor-specific data simulation
  • Implement a Vehicle-Lua extension to manage the sensor runtime
  • Register sensor creation and data access in sensors.lua
  • Connect your sensor to Game-Engine TechCore for centralized polling
  • Expose the sensor in BeamNGpy by creating a new Python sensor class

Once these steps are completed, your custom TAPS sensor will run in real time in BeamNG.tech, expose data through Python

Best regards,
Abdulrahman Saeed
Research Software Engineer
BeamNG.tech Support Team

Hi @asaeed, thanks for the reply. I’m in the middle of implementing the mod following your guide, but I’m struggling with some of the functions.

Could you give me a simple summary of what each function should do?

Thanks!

Hi @MwenDavo,

Sure, here’s a simple summary of what each key function does across the different layers of the sensor architecture for your custom mod:

VLua Controller (e.g., /lua/vehicle/controller/tech/TAPS.lua)
This file handles the actual sensor logic per vehicle.

  • init(data): Receives configuration from GE layer and initializes the sensor.
  • update(dtSim): Called once per simulation step. Computes readings like position, time, and sensor-specific outputs.
  • reset(): Clears current readings after they’ve been polled.
  • getSensorData(): Returns the full list of readings for the current graphics-step.
  • getLatest(): Returns the most recent reading (used for ad-hoc polling).
  • setIsVisualised(value): Enables or disables visualization in the simulation.
  • incrementTimer(dtSim): Tracks time since last poll, used to control update frequency.

VLua Extension (e.g., /lua/vehicle/extensions/tech/TAPS.lua)
This manages and updates all sensor instances for a vehicle.

  • create(data): Instantiates the sensor and connects it to the controller.
  • updateGFX(dtSim): Called every frame to push sensor data to GE Lua for collection.
  • adHocRequest(sensorId, requestId): Handles immediate polling requests.
  • remove(sensorId): Removes the sensor (usually when a vehicle is destroyed).
  • cacheLatestReading(sensorId, reading): Temporarily stores latest values per sensor ID.
  • getTAPSReading(sensorId): Returns stored reading, called from GE Lua.
  • getLatest(sensorId): Returns the most recent single reading.
  • onVehicleDestroyed(vid): Cleans up all sensors if the vehicle is removed from the world.

GE Lua (e.g., /lua/ge/extensions/tech/sensors.lua)
This layer manages sensor creation, polling, and communicating with TechCore / BeamNGpy.

  • createTAPS(vid, args): Attaches sensor to a vehicle and sends creation data to VLua side.
  • getTAPSReadings(sensorId): Used by TechCore to retrieve readings.
  • updateTAPSLastReadings(data): Called from VLua to push sensor data into global state.
  • updateTAPSAdHocRequest(data): Handles ad-hoc request results if polling is on-demand.

TechCore (e.g., /lua/vehicle/extensions/tech/techCore.lua)
Acts as the main dispatcher for exposed sensors in the simulator. This is what BeamNGpy and the simulator rely on.

  • sensorHandlers["TAPS"]: Receives polling requests and returns current data from GE layer using your getTAPSReadings.

BeamNGpy Sensor (e.g., src/beamngpy/sensors/taps.py)
This Python class wraps all sensor behavior on the client side:

  • __init__() - Creates and initializes the TAPS sensor in the simulation with position, reference coordinates, and update timing
  • _open_TAPS() - Registers the sensor with the BeamNG simulator
  • _get_TAPS_id() - Gets unique sensor ID from simulator
  • poll() - Retrieves TAPS readings
  • _poll_TAPS_GE() - Gets bulk sensor data from graphics engine
  • _poll_TAPS_VE() - Gets immediate sensor data from vehicle engine
  • send_ad_hoc_poll_request() - Requests immediate sensor reading
  • is_ad_hoc_poll_request_ready() - Checks if ad-hoc request is complete
  • collect_ad_hoc_poll_request() - Retrieves ad-hoc sensor data
  • set_requested_update_time() - Changes sensor update frequency
  • set_is_visualised() - Toggles sensor visualization in simulator
  • remove() - Removes sensor from simulation
  • _close_TAPS() - Unregisters sensor from BeamNG

Let me know which function you’re stuck on and happy to help more specifically.

Best regards,
Abdulrahman Saeed
Research Software Engineer
BeamNG.tech Support Team

Hi @asaeed! Thanks to your help I’ve managed to implement the controller, the extension and the GE Lua components. While I still have to finish the rest, is there a way to test what I currently have? Maybe with the GE Lua console in-game?

Hello @MwenDavo,

you can load your controller from GE lua console by

extensions.load('<EXTENSION>') # if it's located uner the main extension folder

extensions.load('tech/<EXTENSION>') #If it's located under the tech folder 
tech_<EXTENSION>.<FUNCTION>('<FUNCTION_INPUT')

extensions.unload(<EXTENSION>) #To unload your extension

you could load the controller without extension by

 controller.loadControllerExternal('tech/<CONTROLLER>', '<CONTROLLER>', {<ARGS>})

For further details:

Hi @asaeed, thanks for the reply. I’ve managed to load the sensor into a vehicle, but the GE Lua console is getting constant errors like this:

Currently I have the updateTAPSLastReading function on the mod itself, in the following path: TAPS/lua/ge/extensions/tech/sensors.lua. The function has been declared as part of the module with the expression M.updateTAPSLastReadings = updateTAPSLastReadings

My guess would be I need to move those functions to the ones on the game itself, since maybe they are being overwritten by my own file? I tried changing the name of the file and adding M.dependencies = { "tech_sensors" }, but the outcome is the same.

Hi again. I managed to fix the previous issue and completed the implementation of the techCore layer.

Now I’m trying to get the connection from BeamNGPy to the sensor working. Here’s the Python project where you can see the taps.py file with the BeamNGPy TAPS sensor implementation, and Here’s the mod itself with the sensor implementation on BeamNG.tech.

This is the error shown on the Python terminal:

Traceback (most recent call last):
  File "C:\Users\David Gillig\PycharmProjects\PythonProject\main.py", line 21, in <module>
    taps_v1 = TAPS("TAPS", bng, main_vehicle)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\David Gillig\PycharmProjects\PythonProject\sensor\taps.py", line 33, in __init__
    self._open_TAPS(
  File "C:\Users\David Gillig\PycharmProjects\PythonProject\sensor\taps.py", line 182, in _open_TAPS
    self.send_ack_ge(type="OpenTAPS", ack="OpenedTAPS", **data)
  File "C:\Users\David Gillig\PycharmProjects\PythonProject\.venv\Lib\site-packages\beamngpy\connection\comm_base.py", line 87, in send_ack_ge
    CommBase._send_ack(self.bng.connection, type, ack=ack, **kwargs)
  File "C:\Users\David Gillig\PycharmProjects\PythonProject\.venv\Lib\site-packages\beamngpy\connection\comm_base.py", line 43, in _send_ack
    response.ack(ack)
  File "C:\Users\David Gillig\PycharmProjects\PythonProject\.venv\Lib\site-packages\beamngpy\connection\connection.py", line 288, in ack
    message = self.recv()
              ^^^^^^^^^^^
  File "C:\Users\David Gillig\PycharmProjects\PythonProject\.venv\Lib\site-packages\beamngpy\connection\connection.py", line 280, in recv
    raise message

beamngpy.logging.BNGError: The request was not handled by BeamNG.tech. This can mean:
- an incompatible version of BeamNG.tech/BeamNGpy
- an internal error in BeamNG.tech
- incorrectly implemented custom command

Hello @MwenDavo,

The error means your custom sensor command isn’t being handled in techCore.

Recommended minimal checks, in order:

  • Ensure the vehicle controller is attached/loaded (controller.loadControllerExternal) so vehicle-side logic can process the request.
  • Ensure the GE extension is explicitly loaded in the GE VM (extensions.load) so the command exists before the Python call.
  • In techCore, register a handler for your sensor and return the exact acknowledgment string BeamNGpy expects.
  • Retry from BeamNGpy after the above is verified.

If the request still isn’t handled, compare the expected vs actual ack and review GE/Vehicle logs around the open request.

Best of luck!

Hi @asaeed. Thanks for the reply.

I think the problem may be related to the way the techCore functions are implemented. Currently I have a techCore.py file located in the mod folder, with the following path lua/ge/extensions/tech/techCore.py.

On that file I define the extension variable M, declare tech_techCore as a dependency and define the following functions:

  • M.onSerialize
  • M.onDeserialized
  • M.handleOpenTAPS
  • M.handleGetTAPSId
  • M.handleCloseTAPS
  • M.handlePollTAPSGE
  • M.handleSendAdHocRequestTAPS
  • M.handleIsAdHocPollRequestReadyTAPS
  • M.handleCollectAdHocPollRequestTAPS
  • M.handleSetTAPSRequestUpdateTime
  • M.handleSetTAPSIsVisualized

I believe this is overwriting the original techCore file that comes with the game, and is not being loaded correctly by it. Is there a correct way to implement these functions so that my own file can supersede the original file without overwriting it? or should I modify the techCore file inside the game files directly?

Hello @MwenDavo!

You can include a techCore.py in your mod, but it won’t be loaded by the GE Lua VM—only .lua files under lua/ge/extensions are recognized. If you meant tech/techCore.lua, you cannot have two extensions with the same canonical name (the stock one is tech_techCore). Don’t overwrite or shadow the built-in file.

Recommended approach:

  • Keep the stock tech_techCore.
  • Create your own GE Lua extension with a unique name (e.g., tech/tapsCore.lua → tech_tapsCore).
  • Declare a dependency on tech_techCore (M.dependencies = {“tech_techCore”}) and register your TAPS handlers in your own extension.
  • Explicitly load your extension from the GE Lua console (pattern: extensions.load(‘tech/…’)).
  • If you need a vehicle-side controller, attach it using controller.loadControllerExternal(…).

This keeps your mod isolated, avoids conflicts, and survives updates.

Hi @asaeed , thanks for the reply.

I’ve changed the name of the file to TAPSCore.lua, but I have a few questions regarding its usage:

  • Should I keep the onSerialize() and onDeserialized() functions in TAPSCore.lua? or should this functionality be added to the techCore.lua file located in the game files?
  • I’m still having trouble getting the extension to load from BeamNGPy. I have the following line: bng.open(["tech_TAPSCore"],'-gfx', 'vk'), but I’m getting this on the logs: 2.42691|E|GELua.extensions| extension unavailable: "tech_TAPSCore" at location: "tech/TAPSCore". Seems like mod extensions are not available to load from BeamNGPy, no matter where they are located.

Hi @MwenDavo,

Apologies for the confusion in my earlier reply — I was mistaken.

You’re correct that techCore is the main pipeline used by BeamNGpy, so your custom logic does need to override the original techCore extension.

In this case, it’s okay to provide your own techCore.lua in your mod (lua/ge/extensions/tech/techCore.lua), as long as it’s structured properly and implements the expected handlers (e.g. handleOpenTAPS, etc.).

Thanks for catching that — and again, sorry for the mix-up! Let me know if you run into any other issues.

Hi @asaeed, and thanks for the reply.

I’ve been merging the official techCore.lua file with my own so the connection to BeamNGPy can work. Meanwhile, I’ve been trying to diagnose the second issue I posted on my previous post, which is the fact that I can’t load the extension through Python.

When using this line on my python script to start the simulator bng.open(["tech/TAPS"],'-gfx', 'vk') the following line appears on the log, proving that the extension is not being found:
3.146|E|GELua.extensions|extension unavailable: "tech_TAPS" at location: "tech/TAPS".

However, when using the extensions.load("tech/TAPS"), it loads correctly. Maybe there is an issue when loading mod extensions through BeamNGpy?

Hi @MwenDavo,

You hit the nail on the head! The issue is that bng.open(extensions=...) passes the load command to the Simulation’s startup arguments. This executes very early (milliseconds after launch), often before the Mod Manager has finished mounting your mod.

Since tech/TAPS is a mod, it simply isn’t visible to the simulation yet when that first command runs.

The Solution:
Don’t pass the extension to open(). Instead, load it explicitly after the connection is established. This ensures the simulation and all mods are fully loaded before you try to access them.

# Open the game without the extension first
bng.open(launch=True) 

# Now that the game is fully loaded and connected, load your mod
bng.control.queue_lua_command("extensions.load('tech/TAPS')

Best regards,
Abdulrahman Saeed
Research Software Engineer
BeamNG.tech Support Team