Repository: arenaxr/arena-py (language: python)

Issue:
feat(objects, attributes): added automated support for full schema with docstrings



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bf5c26e..26f74cc1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 arena-py notable changes. Started 2021-02-19 (version 0.1.12).
 
+### Features
+
+* **objects, attributes:** added automated support for full schema with docstrings ([#171](https://github.com/arenaxr/arena-py/issues/171)) ([359181f](https://github.com/arenaxr/arena-py/commit/359181f293fe14ec1536be81ee8d4d2a39b39c3b))
+
 ## [0.8.0](https://github.com/arenaxr/arena-py/compare/v0.7.0...v0.8.0) (2023-11-01)
 
 * **BREAKING CHANGE**: All `arena-user` attributes (descriptors of users in the scene) now are published under the
diff --git a/arena/arena_mqtt.py b/arena/arena_mqtt.py
index 609f0652..c9decd81 100644
--- a/arena/arena_mqtt.py
+++ b/arena/arena_mqtt.py
@@ -7,13 +7,20 @@
 import ssl
 import sys
 from datetime import datetime
-import threading
 
 import paho.mqtt.client as mqtt
 
 from .auth import ArenaAuth
 from .event_loop import *
-from .utils import ArenaCmdInterpreter
+
+from .env import (
+    MQTTH,
+    REALM,
+    ARENA_USERNAME,
+    ARENA_PASSWORD,
+    NAMESPACE,
+    _get_env
+)
 
 class ArenaMQTT(object):
     """
@@ -35,8 +42,8 @@ def __init__(
                 debug = False,
                 **kwargs
             ):
-        if os.environ.get("MQTTH"):
-            self.web_host = os.environ["MQTTH"]
+        if os.environ.get(MQTTH):
+            self.web_host = _get_env(MQTTH)
             print(f"Using Host from 'MQTTH' env variable: {self.web_host}")
         elif "host" in kwargs and kwargs["host"]:
             self.web_host = kwargs["host"]
@@ -45,8 +52,8 @@ def __init__(
             # Use default "web_host", helps avoid and web vs mqtt host and other user setup confusion
             self.web_host = web_host
 
-        if os.environ.get("REALM"):
-            self.realm = os.environ["REALM"]
+        if os.environ.get(REALM):
+            self.realm = _get_env(REALM)
             print(f"Using Realm from 'REALM' env variable: {self.realm}")
         elif "realm" in kwargs and kwargs["realm"]:
             self.realm = kwargs["realm"]
@@ -63,10 +70,10 @@ def __init__(
         token = None
         self.remote_auth_token = {}  # provide reference for downloaded token
         self.auth = ArenaAuth()
-        if os.environ.get("ARENA_USERNAME") and os.environ.get("ARENA_PASSWORD"):
+        if os.environ.get(ARENA_USERNAME) and os.environ.get(ARENA_PASSWORD):
             # auth 1st: use passed in env var
-            self.username = os.environ["ARENA_USERNAME"]
-            token = os.environ["ARENA_PASSWORD"]
+            self.username = _get_env(ARENA_USERNAME)
+            token = _get_env(ARENA_PASSWORD)
             self.auth.store_environment_auth(self.username, token)
         else:
             if self.scene:
@@ -82,8 +89,8 @@ def __init__(
                     # auth 3rd: use the user account online
                     self.username = self.auth.authenticate_user(self.web_host)
 
-        if os.environ.get("NAMESPACE"):
-            self.namespace = os.environ["NAMESPACE"]
+        if os.environ.get(NAMESPACE):
+            self.namespace = _get_env(NAMESPACE)
         elif "namespace" not in kwargs or ("namespace" in kwargs and kwargs["namespace"] is None):
             self.namespace = self.username
         else:
@@ -146,19 +153,6 @@ def __init__(
         self.mqttc.on_disconnect = self.on_disconnect
         self.mqttc.on_publish = self.on_publish
 
-        # setup msg counters
-        self.msg_io = {
-            'last_rcv_time': None,
-            'last_pub_time': None,
-            'rcv_msgs': 0,
-            'pub_msgs': 0,
-            'rcv_msgs_per_sec': 0.0,
-            'pub_msgs_per_sec': 0.0,
-            "rcv_queue_size": 0,
-            "pub_queue_size": 0,
-        }
-        self.msg_rate_time_start = datetime.now()
-
         # add main message processing + callbacks loop to tasks
         self.run_async(self.process_message)
 
@@ -166,15 +160,8 @@ def __init__(
         self.run_forever(self.network_latency_update,
                          interval_ms=network_latency_interval)
 
-        # update message rate every second
-        self.run_forever(self.msg_rate_update,
-                         interval_ms=1000)
-
         self.msg_queue = asyncio.Queue()
 
-        # setup event to let others wait on connection
-        self.connected_evt = threading.Event()
-
         # connect to mqtt broker
         if "port" in kwargs:
             port = kwargs["port"]
@@ -191,15 +178,6 @@ def __init__(
             print(f'MQTT connect error to {self.mqtt_host}, port={port}: Result Code={err}')
         self.mqttc.socket().setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2048)
 
-        # check if we want to start the command interpreter
-        enable_interp = os.getenv("ENABLE_INTERPRETER", 'False').lower() in ('true', '1', 't')
-        if enable_interp:
-            self.cmd_interpreter = ArenaCmdInterpreter(self,
-                                                       show_attrs=('config_data', 'scene', 'users', 'all_objects', 'msg_io'),
-                                                       get_callables=('persisted_objs', 'persisted_scene_option', 'writable_scenes', 'user_list'))
-            self.cmd_interpreter.start_thread(self.connected_evt)
-
-
     def parse_cli(self):
         """
         Reusable command-line options to give apps flexible options to avoid hard-coding locations.
@@ -244,15 +222,6 @@ def network_latency_update(self):
         # publish empty message with QoS of 2 to update latency
         self.mqttc.publish(self.latency_topic, "", qos=2)
 
-    def msg_rate_update(self):
-        """Update Message rate"""
-        elapsed = datetime.now() - self.msg_rate_time_start
-        if elapsed.seconds > 0:
-            self.msg_io['rcv_msgs_per_sec'] = round(self.msg_io['rcv_msgs']  / elapsed.seconds, 2)
-            self.msg_io['pub_msgs_per_sec'] = round(self.msg_io['pub_msgs']  / elapsed.seconds, 2)
-            self.msg_io['rcv_queue_size'] = self.msg_queue.qsize()
-            self.msg_io['pub_queue_size'] = len(self.mqttc._out_packet)
-
     def run_once(self, func=None, **kwargs):
         """Runs a user-defined function on startup"""
         if func is not None:
@@ -329,9 +298,6 @@ def on_connect(self, client, userdata, flags, rc):
             client.subscribe(self.subscribe_topic)
             client.message_callback_add(self.subscribe_topic, self.on_message)
 
-            # set event
-            self.connected_evt.set()
-
             # reset msg rate time
             self.msg_rate_time_start = datetime.now()
 
@@ -345,8 +311,6 @@ def on_message(self, client, userdata, msg):
         # ignore own messages
         if mqtt.topic_matches_sub(self.ignore_topic, msg.topic):
             return
-        self.msg_io['last_rcv_time'] = datetime.now()
-        self.msg_io['rcv_msgs'] = self.msg_io['rcv_msgs'] + 1
         self.msg_queue.put_nowait(msg)
 
     async def process_message(self):
@@ -365,10 +329,6 @@ def disconnect(self):
             self.end_program_callback(self)
         self.mqttc.disconnect()
 
-    def on_publish(self, client, userdata, mid):
-        self.msg_io['last_pub_time'] = datetime.now()
-        self.msg_io['pub_msgs'] = self.msg_io['pub_msgs'] + 1
-
     def message_callback_add(self, sub, callback):
         """Subscribes to new topic and adds callback"""
         self.mqttc.subscribe(sub)
@@ -378,3 +338,11 @@ def message_callback_remove(self, sub):
         """Unsubscribes to topic and removes callback"""
         self.mqttc.unsubscribe(sub)
         self.mqttc.message_callback_remove(sub)
+
+    def rcv_queue_len(self): 
+        """Return receive queue length"""
+        self.msg_queue.qsize()
+        
+    def pub_queue_len(self): 
+        """Return publish queue length"""
+        return self.mqttc._out_packet
\ No newline at end of file
diff --git a/arena/attributes/__init__.py b/arena/attributes/__init__.py
index afca82e7..0e4d13ea 100644
--- a/arena/attributes/__init__.py
+++ b/arena/attributes/__init__.py
@@ -1,40 +1,68 @@
-from .animation_mixer import AnimationMixer
-from .animation import Animation
-from .attribute import Attribute
-from .color import Color
-from .data import Data
-from .goto_url import GotoUrl
-from .impulse import Impulse
-from .jitsi_video import JitsiVideo
-from .material import Material
-from .morph import Morph
-from .physics import Physics
-from .position import Position
-from .rotation import Rotation
-from .scale import Scale
-from .sound import Sound
-from .spe_particles import SpeParticles
-from .text_input import TextInput
-from .video_control import VideoControl
+# This file is auto-generated from github.com/arenaxr/arena-schema, changes here may be overwritten.
+
+from .translate import *
+from .attribute import *
+from .color import *
+from .data import *
+from .animation import *
+from .animation_mixer import *
+from .armarker import *
+from .attribution import *
+from .blip import *
+from .box_collision_listener import *
+from .click_listener import *
+from .dynamic_body import *
+from .gltf_model_lod import *
+from .gltf_morph import *
+from .goto_landmark import *
+from .goto_url import *
+from .impulse import *
+from .jitsi_video import *
+from .landmark import *
+from .material import *
+from .material_extras import *
+from .multisrc import *
+from .position import *
+from .remote_render import *
+from .rotation import *
+from .scale import *
+from .shadow import *
+from .sound import *
+from .spe_particles import *
+from .static_body import *
+from .textinput import *
+from .video_control import *
 
-# [TODO]: do something with this
 ATTRIBUTE_TYPE_MAP = {
-    "animation-mixer": AnimationMixer,
-    "animation": Animation,
     "attribute": Attribute,
     "color": Color,
     "data": Data,
+    "animation": Animation,
+    "animation_mixer": AnimationMixer,
+    "armarker": Armarker,
+    "attribution": Attribution,
+    "blip": Blip,
+    "box_collision_listener": BoxCollisionListener,
+    "click_listener": ClickListener,
+    "dynamic_body": DynamicBody,
+    "gltf_model_lod": GltfModelLod,
+    "gltf_morph": GltfMorph,
+    "goto_landmark": GotoLandmark,
     "goto_url": GotoUrl,
     "impulse": Impulse,
     "jitsi_video": JitsiVideo,
+    "landmark": Landmark,
     "material": Material,
-    "morph": Morph,
-    "physics": Physics,
+    "material_extras": MaterialExtras,
+    "multisrc": Multisrc,
     "position": Position,
+    "remote_render": RemoteRender,
     "rotation": Rotation,
     "scale": Scale,
+    "shadow": Shadow,
     "sound": Sound,
     "spe_particles": SpeParticles,
-    "text_input": TextInput,
+    "static_body": StaticBody,
+    "textinput": Textinput,
     "video_control": VideoControl,
 }
diff --git a/arena/attributes/animation.py b/arena/attributes/animation.py
index 1af39772..ca912179 100644
--- a/arena/attributes/animation.py
+++ b/arena/attributes/animation.py
@@ -6,8 +6,26 @@
 
 class Animation(Attribute):
     """
-    Animation Attribute.
-    Usage: Animation(...)
+    Animation attribute class to manage its properties in the ARENA: Animate and tween values.  More properties at (https://aframe.io/docs/1.5.0/components/animation.html) A-Frame Animation component. Easing properties are detailed at (https://easings.net) easings.net.
+    Usage: animation=Animation(...)
+    
+    :param bool autoplay: Whether or not the animation should autoplay. Should be specified if the animation is defined for the animation-timeline component (currently not supported). Defaults to 'True' (optional)
+    :param float delay: How long (milliseconds) to wait before starting. (optional)
+    :param str dir: Which dir to go from from to to. Allows [normal, alternate, reverse] Defaults to 'normal' (optional)
+    :param float dur: How long (milliseconds) each cycle of the animation is. Defaults to '1000' (optional)
+    :param str easing: Easing function of animation. To ease in, ease out, ease in and out. See easings.net for more. Allows [easeInQuad, easeInCubic, easeInQuart, easeInQuint, easeInSine, easeInExpo, easeInCirc, easeInBack, easeInElastic, easeOutQuad, easeOutCubic, easeOutQuart, easeOutQuint, easeOutSine, easeOutExpo, easeOutCirc, easeOutBack, easeOutElastic, easeInOutQuad, easeInOutCubic, easeInOutQuart, easeInOutQuint, easeInOutSine, easeInOutExpo, easeInOutCirc, easeInOutBack, easeInOutElastic, linear] Defaults to 'easeInQuad' (optional)
+    :param float elasticity: How much to bounce (higher is stronger). Defaults to '400' (optional)
+    :param bool enabled: If disabled, animation will stop and startEvents will not trigger animation start. Defaults to 'True' (optional)
+    :param str from: Initial value at start of animation. If not specified, the current property value of the entity will be used (will be sampled on each animation start). It is best to specify a from value when possible for stability. (optional)
+    :param bool isRawProperty: Flag to animate an arbitrary object property outside of A-Frame components for better performance. If set to true, for example, we can set property to like components.material.material.opacity. If property starts with components or object3D, this will be inferred to true. (optional)
+    :param str loop: How many times the animation should repeat. If the value is true, the animation will repeat infinitely. (optional)
+    :param list[dict] pauseEvents: Comma-separated list of events to listen to trigger pause. Can be resumed with resumeEvents. (optional)
+    :param str property: Property to animate. Can be a component name, a dot-delimited property of a component (e.g., material.color), or a plain attribute. (optional)
+    :param list[dict] resumeEvents: Comma-separated list of events to listen to trigger resume after pausing. (optional)
+    :param bool round: Whether to round values. (optional)
+    :param list[dict] startEvents: Comma-separated list of events to listen to trigger a restart and play. Animation will not autoplay if specified. startEvents will restart the animation, use pauseEvents to resume it. If there are other animation components on the entity animating the same property, those animations will be automatically paused to not conflict. (optional)
+    :param str to: Target value at end of animation. (optional)
+    :param str type: Right now only supports color for tweening isRawProperty color XYZ/RGB vector values. (optional)
     """
     def __init__(self, **kwargs):
         if "start" in kwargs:
diff --git a/arena/attributes/animation_mixer.py b/arena/attributes/animation_mixer.py
index 4f614f23..b051ff1d 100644
--- a/arena/attributes/animation_mixer.py
+++ b/arena/attributes/animation_mixer.py
@@ -2,6 +2,18 @@
 
 class AnimationMixer(Animation):
     """
-    Animation Attribute.
-    Usage: AnimationMixer(...)
+    AnimationMixer attribute class to manage its properties in the ARENA: A list of available animations can usually be found by inspecting the model file or its documentation. All animations will play by default. To play only a specific set of animations, use wildcards: animation-mixer='clip: run_*'.  More properties at (https://github.com/n5ro/aframe-extras/tree/master/src/loaders#animation) A-Frame Extras Animation.
+    Usage: animation_mixer=AnimationMixer(...)
+    
+    :param bool clampWhenFinished: If true, halts the animation at the last frame. (optional)
+    :param str clip: Name of the animation clip(s) to play. Accepts wildcards. Defaults to '*' (optional)
+    :param float crossFadeDuration: Duration of cross-fades between clips, in seconds. (optional)
+    :param float duration: Duration of the animation, in seconds (0 = auto). (optional)
+    :param str loop: In repeat and pingpong modes, the clip plays once plus the specified number of repetitions. For pingpong, every second clip plays in reverse. Allows [once, repeat, pingpong] Defaults to 'repeat' (optional)
+    :param str repetitions: Number of times to play the clip, in addition to the first play (empty string = Infinity). Repetitions are ignored for loop: once. (optional)
+    :param float startAt: Sets the start of an animation to a specific time (in milliseconds). This is useful when you need to jump to an exact time in an animation. The input parameter will be scaled by the mixer's timeScale. (optional)
+    :param float timeScale: Scaling factor for playback speed. A value of 0 causes the animation to pause. Negative values cause the animation to play backwards. Defaults to '1' (optional)
     """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/armarker.py b/arena/attributes/armarker.py
new file mode 100644
index 00000000..5781ab84
--- /dev/null
+++ b/arena/attributes/armarker.py
@@ -0,0 +1,22 @@
+from .attribute import Attribute
+
+
+class Armarker(Attribute):
+    """
+    Armarker attribute class to manage its properties in the ARENA: A location marker (such as an AprilTag, a lightAnchor, or an UWB tag), used to anchor scenes, or scene objects, in the real world.
+    Usage: armarker=Armarker(...)
+    
+    :param bool buildable: Whether tag has 'dynamic' toggled on click. Used to position a tag, then lock into position. (optional)
+    :param bool dynamic: Dynamic tag, not used for localization. E.g., to move object to which this ARMarker component is attached to. Requires permissions to update the scene (if dynamic=true). (optional)
+    :param float ele: Tag elevation in meters. (optional)
+    :param float lat: Tag latitude. (optional)
+    :param float long: Tag longitude. (optional)
+    :param str markerid: The marker id (e.g. for AprilTag 36h11 family, an integer in the range [0, 586]). Defaults to '0' (optional)
+    :param str markertype: The marker type, technology-based. Allows [apriltag_36h11, lightanchor, uwb, vive, optitrack] Defaults to 'apriltag_36h11' (optional)
+    :param bool publish: Publish detections. Send detections to external agents (e.g. external builder script that places new markers in the scene). If dynamic=true and publish=true, object position is not updated (left up to external agent). (optional)
+    :param float size: Tag size in millimeters. Defaults to '150' (optional)
+    :param str url: URL associated with the tag. (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/attribution.py b/arena/attributes/attribution.py
new file mode 100644
index 00000000..a3a8ca89
--- /dev/null
+++ b/arena/attributes/attribution.py
@@ -0,0 +1,20 @@
+from .attribute import Attribute
+
+
+class Attribution(Attribute):
+    """
+    Attribution attribute class to manage its properties in the ARENA: Attribution Component. Saves attribution data in any entity.
+    Usage: attribution=Attribution(...)
+    
+    :param str author: Author name; e.g. 'Vaptor-Studio'. Defaults to 'Unknown' (optional)
+    :param str authorURL: Author homepage/profile; e.g. https://sketchfab.com/VapTor. (optional)
+    :param bool extractAssetExtras: Extract attribution info from asset extras; will override attribution info given (default: true). Defaults to 'True' (optional)
+    :param str license: License summary/short name; e.g. 'CC-BY-4.0'. Defaults to 'Unknown' (optional)
+    :param str licenseURL: License URL; e.g. http://creativecommons.org/licenses/by/4.0/. (optional)
+    :param str source: Model source e.g. 'Sketchfab'. Defaults to 'Unknown' (optional)
+    :param str sourceURL: Model source URL; e.g. https://sketchfab.com/models/2135501583704537907645bf723685e7. (optional)
+    :param str title: Model title; e.g. 'Spinosaurus'. Defaults to 'No Title' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/blip.py b/arena/attributes/blip.py
new file mode 100644
index 00000000..acdb4429
--- /dev/null
+++ b/arena/attributes/blip.py
@@ -0,0 +1,18 @@
+from .attribute import Attribute
+
+
+class Blip(Attribute):
+    """
+    Blip attribute class to manage its properties in the ARENA: When the object is created or deleted, it will animate in/out of the scene instead of appearing/disappearing instantly. Must have a geometric mesh.
+    Usage: blip=Blip(...)
+    
+    :param bool applyDescendants: Apply blipout effect to include all descendents. Does not work for blipin. (optional)
+    :param bool blipin: Animate in on create, set false to disable. Defaults to 'True' (optional)
+    :param bool blipout: Animate out on delete, set false to disable. Defaults to 'True' (optional)
+    :param float duration: Animation duration in milliseconds. Defaults to '750' (optional)
+    :param str geometry: Geometry of the blipout plane. Allows [rect, disk, ring] Defaults to 'rect' (optional)
+    :param str planes: Which which clipping planes to use for effect. A top plane clips above it, bottom clips below it. Allows [both, top, bottom] Defaults to 'both' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/box_collision_listener.py b/arena/attributes/box_collision_listener.py
new file mode 100644
index 00000000..89d4ad89
--- /dev/null
+++ b/arena/attributes/box_collision_listener.py
@@ -0,0 +1,14 @@
+from .attribute import Attribute
+
+
+class BoxCollisionListener(Attribute):
+    """
+    BoxCollisionListener attribute class to manage its properties in the ARENA: Listen for bounding-box collisions with user camera and hands. Must be applied to an object or model with geometric mesh. Collisions are determined by course bounding-box overlaps.
+    Usage: box_collision_listener=BoxCollisionListener(...)
+    
+    :param bool dynamic: Set true for a moving object, which should have its bounding box recalculated regularly to determine proper collision. (optional)
+    :param bool enabled: Publish detections, set `false` to disable. Defaults to 'True' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/click_listener.py b/arena/attributes/click_listener.py
new file mode 100644
index 00000000..c993a0ca
--- /dev/null
+++ b/arena/attributes/click_listener.py
@@ -0,0 +1,14 @@
+from .attribute import Attribute
+
+
+class ClickListener(Attribute):
+    """
+    ClickListener attribute class to manage its properties in the ARENA: Object will listen for clicks.
+    Usage: click_listener=ClickListener(...) or clickable=ClickListener(...)  or clickable=True
+
+    :param bool bubble: Set false to prevent click events from bubbling up to parent objects. See <a href='https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#event_bubbling'>Event Bubbling</a>. Defaults to 'True' (optional)
+    :param bool enabled: Publish events, set false to disable. Defaults to 'True' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/data.py b/arena/attributes/data.py
index 371cc22d..199b3a62 100644
--- a/arena/attributes/data.py
+++ b/arena/attributes/data.py
@@ -4,15 +4,58 @@
 from .rotation import Rotation
 from .goto_url import GotoUrl
 from .jitsi_video import JitsiVideo
-from .physics import Physics
+from .dynamic_body import Physics
 from .scale import Scale
 from .material import Material
 from .color import Color
 from .video_control import VideoControl
+from .translate import ATTRIBUTE_CLASS_TRANSLATION, KEYWORD_ATTRIBUTE_TRANSLATION
 
 class Data(Attribute):
     """
-    Data Attribute. Wraps all attributes in JSON.
+    Data attribute class to manage its properties in the ARENA: Wraps all attributes in JSON.
+    Usage: data=Data(...)
+    
+    :param dict animation: Animate and tween values. (optional)
+    :param dict animation_mixer: A list of available animations can usually be found by inspecting the model file or its documentation. All animations will play by default. To play only a specific set of animations, use wildcards: animation-mixer='clip: run_*'. (optional)
+    :param dict armarker: A location marker (such as an AprilTag, a lightAnchor, or an UWB tag), used to anchor scenes, or scene objects, in the real world. (optional)
+    :param dict attribution: Attribution Component. Saves attribution data in any entity. (optional)
+    :param dict blip: When the object is created or deleted, it will animate in/out of the scene instead of appearing/disappearing instantly. Must have a geometric mesh. (optional)
+    :param dict box_collision_listener: Listen for bounding-box collisions with user camera and hands. Must be applied to an object or model with geometric mesh. Collisions are determined by course bounding-box overlaps. (optional)
+    :param bool buffer: Transform geometry into a BufferGeometry to reduce memory usage at the cost of being harder to manipulate (geometries only: box, circle, cone, ...). Defaults to 'True' (optional)
+    :param dict click_listener: Object will listen for clicks. (optional)
+    :param str collision_listener: Name of the collision-listener, default can be empty string. Collisions trigger click events. (optional)
+    :param dict dynamic_body: A freely-moving object. Dynamic bodies have mass, collide with other objects, bounce or slow during collisions, and fall if gravity is enabled. (optional)
+    :param dict gltf_model_lod: Simple switch between the default gltf-model and a detailed one when a user camera is within specified distance (optional)
+    :param dict gltf_morph: Allows you to target and control a gltf model's morphTargets created in Blender. (optional)
+    :param dict goto_landmark: Teleports user to the landmark with the given name; Requires click-listener. (optional)
+    :param dict goto_url: Goto given URL; Requires click-listener. (optional)
+    :param bool hide_on_enter_ar: Hide object when entering AR. Remove component to *not* hide. (optional)
+    :param bool hide_on_enter_vr: Hide object when entering VR. Remove component to *not* hide. (optional)
+    :param dict impulse: Apply an impulse to an object to set it in motion. This happens in conjunction with an event. Requires click-listener and physics. (optional)
+    :param dict jitsi_video: Apply a jitsi video source to the geometry. (optional)
+    :param dict landmark: Define entities as a landmark; Landmarks appears in the landmark list and you can move (teleport) to them; You can define the behavior of the teleport: if you will be at a fixed or random distance, looking at the landmark, fixed offset or if it is constrained by a navmesh (when it exists). (optional)
+    :param str look_at: The look-at component defines the behavior for an entity to dynamically rotate or face towards another entity or position. Use '#my-camera' to face the user camera, otherwise can take either a vec3 position or a query selector to another entity. (optional)
+    :param dict material: The material properties of the object's surface. (optional)
+    :param dict material_extras: Define extra material properties, namely texture encoding, whether to render the material's color and render order. (optional)
+    :param dict modelUpdate: The GLTF-specific `modelUpdate` attribute is an object with child component names as keys. The top-level keys are the names of the child components to be updated. The values of each are nested `position` and `rotation` attributes to set as new values, respectively. Either `position` or `rotation` can be omitted if unchanged. (optional)
+    :param dict multisrc: Define multiple visual sources applied to an object. (optional)
+    :param str parent: Parent's object_id. Child objects inherit attributes of their parent, for example scale and translation. (optional)
+    :param dict position: 3D object position. (optional)
+    :param dict remote_render: Whether or not an object should be remote rendered [Experimental]. (optional)
+    :param dict rotation: 3D object rotation in quaternion representation; Right-handed coordinate system. Euler degrees are deprecated in wire message format. (optional)
+    :param dict scale: 3D object scale. (optional)
+    :param bool screenshareable: Whether or not a user can screenshare on an object. Defaults to 'True' (optional)
+    :param dict shadow: Shadow (optional)
+    :param bool show_on_enter_ar: Show object when entering AR. Hidden otherwise. (optional)
+    :param bool show_on_enter_vr: Show object when entering VR. Hidden otherwise. (optional)
+    :param bool skipCache: Disable retrieving the shared geometry object from the cache. (geometries only: box, circle, cone, ...). (optional)
+    :param dict sound: The sound component defines the entity as a source of sound or audio. The sound component is positional and is thus affected by the component's position. (optional)
+    :param dict spe_particles: GPU based particle systems in A-Frame. (optional)
+    :param dict static_body: A fixed-position or animated object. Other objects may collide with static bodies, but static bodies themselves are unaffected by gravity and collisions. (optional)
+    :param dict textinput: Opens an HTML prompt when clicked. Sends text input as an event on MQTT. Requires click-listener. (optional)
+    :param str url: Use File Store paths under 'store/users/username', see CDN and other storage options in the description above. (optional)
+    :param dict video_control: Video Control. (optional)
     """
     def __init__(self, **kwargs):
         data = {}
@@ -24,52 +67,6 @@ def update_data(cls, data, new_data):
         new_data = new_data.get("data", new_data)
         dash_words = []
         for k,v in new_data.items():
-            # the dashes in these specific keys need to be replaced with underscores
-            if k == "goto-url":
-                dash_words += [k]
-                k = "goto_url"
-                if isinstance(v, dict):
-                    data[k] = GotoUrl(**v)
-                else:
-                    data[k] = v
-
-            if k == "jitsi-video":
-                dash_words += [k]
-                k = "jitsi_video"
-                if isinstance(v, dict):
-                    data[k] = JitsiVideo(**v)
-                else:
-                    data[k] = v
-
-            if k == "video-control":
-                dash_words += [k]
-                k = "video_control"
-                if isinstance(v, dict):
-                    data[k] = VideoControl(**v)
-                else:
-                    data[k] = v
-
-            # this could be called "clickable"
-            if k == "click-listener":
-                if "clickable" in data:
-                    k = "clickable"
-                else:
-                    dash_words += [k]
-                    k = "click_listener"
-                data[k] = v
-
-            # this could be called "physics"
-            if k == "dynamic-body":
-                if "physics" in data:
-                    k = "physics"
-                else:
-                    dash_words += [k]
-                    k = "dynamic_body"
-                if isinstance(v, dict):
-                    data[k] = Physics(**v)
-                else:
-                    data[k] = v
-
             # allow user to input tuples, lists, dicts, etc for specific Attributes.
             # everything gets converted to corresponding attribute
             if (k == "position" or k == "start" or k == "end") and not isinstance(v, Position):
@@ -127,6 +124,18 @@ def update_data(cls, data, new_data):
                 else:
                     data[k] = v
 
+            # Translate and handle underscores from any other keys.
+            # Must be done last since KEYWORD_ATTRIBUTE_TRANSLATION contains all attributes,
+            # which may interfere with special casing above from, say "rotation" for example.
+            elif k in KEYWORD_ATTRIBUTE_TRANSLATION:
+                if '-' in k:
+                    dash_words += [k]
+                    k = KEYWORD_ATTRIBUTE_TRANSLATION[k]
+                if isinstance(v, dict):
+                    data[k] = ATTRIBUTE_CLASS_TRANSLATION[k](**v)
+                else:
+                    data[k] = v
+
             elif isinstance(v, Attribute):
                 data[k] = v
 
diff --git a/arena/attributes/dynamic_body.py b/arena/attributes/dynamic_body.py
new file mode 100644
index 00000000..38f94c10
--- /dev/null
+++ b/arena/attributes/dynamic_body.py
@@ -0,0 +1,29 @@
+from .attribute import Attribute
+
+
+class DynamicBody(Attribute):
+    """
+    DynamicBody attribute class to manage its properties in the ARENA: A freely-moving object. Dynamic bodies have mass, collide with other objects, bounce or slow during collisions, and fall if gravity is enabled.  More properties at (https://github.com/c-frame/aframe-physics-system/blob/master/CannonDriver.md) A-Frame Physics System.
+    Usage: dynamic_body=DynamicBody(...)
+
+    :param float angularDamping: Resistance to rotation. Defaults to '0.01' (optional)
+    :param str cylinderAxis: Override default axis of bounding cylinder. Requires shape:cylinder. Allows [x, y, z] Defaults to 'y' (optional)
+    :param float linearDamping: Resistance to movement. Defaults to '0.01' (optional)
+    :param float mass: Simulated mass of the object, > 0. Defaults to '5' (optional)
+    :param str shape: Body components will attempt to find an appropriate CANNON.js shape to fit your model. When defining an object you may choose a shape or leave the default, auto. Select a shape carefully, as there are performance implications with different choices. Allows [auto, box, cylinder, sphere, hull, none] Defaults to 'auto' (optional)
+    :param float sphereRadius: Override default radius of bounding sphere. Requires shape:sphere. NaN by default. (optional)
+    :param str type: Define the result of collisions. Dynamic can be moved, Static cannot be moved. Allows [dynamic, static] Defaults to 'dynamic' (optional)
+    """
+
+    def __init__(self, type="static", **kwargs):
+        _type = type
+        if _type != "static" and _type != "dynamic":
+            _type = "none"
+        super().__init__(type=_type, **kwargs)
+
+
+class Physics(DynamicBody):
+    """
+    Alternate name for DynamicBody.
+    Usage: dynamic_body=Physics(...) or physics=Physics(...)
+    """
diff --git a/arena/attributes/gltf_model_lod.py b/arena/attributes/gltf_model_lod.py
new file mode 100644
index 00000000..427ba405
--- /dev/null
+++ b/arena/attributes/gltf_model_lod.py
@@ -0,0 +1,16 @@
+from .attribute import Attribute
+
+
+class GltfModelLod(Attribute):
+    """
+    GltfModelLod attribute class to manage its properties in the ARENA: Simple switch between the default gltf-model and a detailed one when a user camera is within specified distance
+    Usage: gltf_model_lod=GltfModelLod(...)
+    
+    :param float detailedDistance: At what distance to switch between the models. Defaults to '10' (optional)
+    :param str detailedUrl: Alternative 'detailed' gltf model to load by URL. (optional)
+    :param bool retainCache: Whether to skip freeing the detailed model from browser cache (default false). (optional)
+    :param float updateRate: How often user camera is checked for LOD (default 333ms). Defaults to '333' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/gltf_morph.py b/arena/attributes/gltf_morph.py
new file mode 100644
index 00000000..8c3b9e78
--- /dev/null
+++ b/arena/attributes/gltf_morph.py
@@ -0,0 +1,22 @@
+from .attribute import Attribute
+
+
+class GltfMorph(Attribute):
+    """
+    GltfMorph attribute class to manage its properties in the ARENA: Allows you to target and control a gltf model's morphTargets created in Blender.  More properties at (https://github.com/elbobo/aframe-gltf-morph-component) A-Frame GLTF Morph component.
+    Usage: gltf_morph=GltfMorph(...)
+
+    :param str morphtarget: Name of morphTarget, can be found as part of the GLTF model. (optional)
+    :param float value: Value that you want to set that morphTarget to (0 - 1). (optional)
+    """
+
+    def __init__(self, morphtarget, value):
+        self.morphtarget = str(morphtarget)
+        self.value = str(value)
+
+
+class Morph(GltfMorph):
+    """
+    Alternate name for GltfMorph.
+    Usage: gltf_morph=Morph(...)
+    """
diff --git a/arena/attributes/goto_landmark.py b/arena/attributes/goto_landmark.py
new file mode 100644
index 00000000..0081b293
--- /dev/null
+++ b/arena/attributes/goto_landmark.py
@@ -0,0 +1,14 @@
+from .attribute import Attribute
+
+
+class GotoLandmark(Attribute):
+    """
+    GotoLandmark attribute class to manage its properties in the ARENA: Teleports user to the landmark with the given name; Requires click-listener.
+    Usage: goto_landmark=GotoLandmark(...)
+    
+    :param str landmark: Id of landmark to teleport to. (optional)
+    :param str on: Event to listen 'on'. Allows [mousedown, mouseup] Defaults to 'mousedown' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/goto_url.py b/arena/attributes/goto_url.py
index 97108acd..d7f2d2b5 100644
--- a/arena/attributes/goto_url.py
+++ b/arena/attributes/goto_url.py
@@ -2,8 +2,12 @@
 
 class GotoUrl(Attribute):
     """
-    GotoUrl Attribute.
+    GotoUrl attribute class to manage its properties in the ARENA: Goto given URL; Requires click-listener.
     Usage: goto_url=GotoUrl(...)
+    
+    :param str dest:  Allows [popup, newtab, sametab] Defaults to 'sametab' (optional)
+    :param str on:  Allows [mousedown, mouseup] Defaults to 'mousedown' (optional)
+    :param str url: Landmark Url (optional)
     """
     def __init__(self, url, dest="static", on="mousedown", **kwargs):
         if dest != "popup" and dest != "newtab" and dest != "sametab":
diff --git a/arena/attributes/impulse.py b/arena/attributes/impulse.py
index fc772d74..4ebcc793 100644
--- a/arena/attributes/impulse.py
+++ b/arena/attributes/impulse.py
@@ -4,8 +4,12 @@
 
 class Impulse(Attribute):
     """
-    Impulse Attribute.
+    Impulse attribute class to manage its properties in the ARENA: Apply an impulse to an object to set it in motion. This happens in conjunction with an event. Requires click-listener and physics.
     Usage: impulse=Impulse(...)
+    
+    :param dict force: Impulse vector. Defaults to '{'x': 1, 'y': 1, 'z': 1}' (optional)
+    :param str on: Event to listen 'on'. (optional)
+    :param dict position: World position. Defaults to '{'x': 1, 'y': 1, 'z': 1}' (optional)
     """
     def __init__(self, on="mousedown", force=Position(0,0,0), position=Position(0,0,0), **kwargs):
         if isinstance(force, Position):
diff --git a/arena/attributes/jitsi_video.py b/arena/attributes/jitsi_video.py
index d144a79c..b5993b8c 100644
--- a/arena/attributes/jitsi_video.py
+++ b/arena/attributes/jitsi_video.py
@@ -2,8 +2,11 @@
 
 class JitsiVideo(Attribute):
     """
-    Jitsi Video Attribute. For video source.
+    JitsiVideo attribute class to manage its properties in the ARENA: Apply a jitsi video source to the geometry.
     Usage: jitsi_video=JitsiVideo(...)
+    
+    :param str displayName: ARENA or Jitsi display name of the video source; Will be ignored if jitsiId is given. (* change requires reload* ). (optional)
+    :param str jitsiId: JitsiId of the video source; If defined will override displayName. (optional)
     """
     def __init__(self, jitsiId=None, displayName=None):
         # Since jitsiId overrides displayName, do not set None arguments
diff --git a/arena/attributes/landmark.py b/arena/attributes/landmark.py
index e8adda71..90824a0e 100644
--- a/arena/attributes/landmark.py
+++ b/arena/attributes/landmark.py
@@ -4,8 +4,16 @@
 
 class Landmark(Attribute):
     """
-    Landmark Attribute.
-    Usage: Animation(...)
+    Landmark attribute class to manage its properties in the ARENA: Define entities as a landmark; Landmarks appears in the landmark list and you can move (teleport) to them; You can define the behavior of the teleport: if you will be at a fixed or random distance, looking at the landmark, fixed offset or if it is constrained by a navmesh (when it exists).
+    Usage: landmark=Landmark(...)
+    
+    :param str constrainToNavMesh: Teleports should snap to navmesh. Allows [false, any, coplanar] Defaults to 'false' (optional)
+    :param str label: Landmark description to display in the landmark list. (optional)
+    :param bool lookAtLandmark: Set to true to make users face the landmark when teleported to it. Defaults to 'True' (optional)
+    :param dict offsetPosition: Use as a static teleport x,y,z offset. Defaults to '{'x': 0, 'y': 1.6, 'z': 0}' (optional)
+    :param float randomRadiusMax: Maximum radius from the landmark to teleport to. (optional)
+    :param float randomRadiusMin: Minimum radius from the landmark to teleport to. (randomRadiusMax must > 0). (optional)
+    :param bool startingPosition: Set to true to use this landmark as a scene start (spawn) position. If several landmarks with startingPosition=true exist in a scene, one will be randomly selected. (optional)
     """
     def __init__(self, **kwargs):
         if "offsetPosition" in kwargs:
diff --git a/arena/attributes/material.py b/arena/attributes/material.py
index 11b013c1..0e317161 100644
--- a/arena/attributes/material.py
+++ b/arena/attributes/material.py
@@ -2,8 +2,25 @@
 
 class Material(Attribute):
     """
-    Material Attribute. For setting color and opacity.
+    Material attribute class to manage its properties in the ARENA: The material properties of the object's surface.  More properties at (https://aframe.io/docs/1.5.0/components/material.html) A-Frame Material.
     Usage: material=Material(...)
+    
+    :param float alphaTest: Alpha test threshold for transparency. (optional)
+    :param str blending: The blending mode for the material's RGB and Alpha sent to the WebGLRenderer. Allows [none, normal, additive, subtractive, multiply] Defaults to 'normal' (optional)
+    :param str color: Base diffuse color. Defaults to '#7f7f7f' (optional)
+    :param bool depthTest: Whether depth testing is enabled when rendering the material. Defaults to 'True' (optional)
+    :param bool dithering: Whether material is dithered with noise. Removes banding from gradients like ones produced by lighting. Defaults to 'True' (optional)
+    :param bool flatShading: Use THREE.FlatShading rather than THREE.StandardShading. (optional)
+    :param bool npot: Use settings for non-power-of-two (NPOT) texture. (optional)
+    :param dict offset: Texture offset to be used. Defaults to '{'x': 1, 'y': 1}' (optional)
+    :param float opacity: Extent of transparency. If the transparent property is not true, then the material will remain opaque and opacity will only affect color. Defaults to '1' (optional)
+    :param dict repeat: Texture repeat to be used. Defaults to '{'x': 1, 'y': 1}' (optional)
+    :param str shader: Which material to use. Defaults to the standard material. Can be set to the flat material or to a registered custom shader material. Defaults to 'standard' (optional)
+    :param str side: Which sides of the mesh to render. Allows [front, back, double] Defaults to 'front' (optional)
+    :param str src: URI, relative or full path of an image/video file. e.g. 'store/users/wiselab/images/360falls.mp4'. (optional)
+    :param bool transparent: Whether material is transparent. Transparent entities are rendered after non-transparent entities. (optional)
+    :param str vertexColors: Whether to use vertex or face colors to shade the material. Allows [none, vertex, face] Defaults to 'none' (optional)
+    :param bool visible: Whether material is visible. Raycasters will ignore invisible materials. Defaults to 'True' (optional)
     """
     def __init__(self, **kwargs):
         if "opacity" in kwargs:
diff --git a/arena/attributes/material_extras.py b/arena/attributes/material_extras.py
new file mode 100644
index 00000000..dd38cf5f
--- /dev/null
+++ b/arena/attributes/material_extras.py
@@ -0,0 +1,18 @@
+from .attribute import Attribute
+
+
+class MaterialExtras(Attribute):
+    """
+    MaterialExtras attribute class to manage its properties in the ARENA: Define extra material properties, namely texture encoding, whether to render the material's color and render order.  The properties set here access directly Three.js material component.   More properties at (https://threejs.org/docs/#api/en/materials/Material) THREE.js Material.
+    Usage: material_extras=MaterialExtras(...)
+    
+    :param bool colorWrite: Whether to render the material's color. Defaults to 'True' (optional)
+    :param str encoding:  Allows [LinearEncoding, sRGBEncoding, GammaEncoding, RGBEEncoding, LogLuvEncoding, RGBM7Encoding, RGBM16Encoding, RGBDEncoding, BasicDepthPacking, RGBADepthPacking] Defaults to 'sRGBEncoding' (optional)
+    :param float gltfOpacity: Opacity value to apply to the model. 1 is fully opaque, 0 is fully transparent. Defaults to '1' (optional)
+    :param str overrideSrc: Overrides the material source in all meshes of an object (e.g. a basic shape or a GLTF); Use, for example, to change the texture of a GLTF. (optional)
+    :param float renderOrder: Allows the default rendering order of scene graph objects to be overridden. Defaults to '1' (optional)
+    :param bool transparentOccluder: If `true`, will set `colorWrite=false` and `renderOrder=0` to make the material a transparent occluder. (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/model_update.py b/arena/attributes/model_update.py
new file mode 100644
index 00000000..036c625b
--- /dev/null
+++ b/arena/attributes/model_update.py
@@ -0,0 +1,12 @@
+from .attribute import Attribute
+
+
+class ModelUpdate(Attribute):
+    """
+    ModelUpdate attribute class to manage its properties in the ARENA: The GLTF-specific `modelUpdate` attribute is an object with child component names as keys. The top-level keys are the names of the child components to be updated. The values of each are nested `position` and `rotation` attributes to set as new values, respectively. Either `position` or `rotation` can be omitted if unchanged.
+    Usage: modelUpdate=ModelUpdate(...)
+
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/morph.py b/arena/attributes/morph.py
deleted file mode 100644
index 4fed2507..00000000
--- a/arena/attributes/morph.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from .attribute import Attribute
-
-class Morph(Attribute):
-    """
-    Morph Attribute.
-    Usage: Morph(...)
-    """
-    def __init__(self, morphtarget, value):
-        self.morphtarget = str(morphtarget)
-        self.value = str(value)
diff --git a/arena/attributes/multisrc.py b/arena/attributes/multisrc.py
new file mode 100644
index 00000000..1f723ac9
--- /dev/null
+++ b/arena/attributes/multisrc.py
@@ -0,0 +1,14 @@
+from .attribute import Attribute
+
+
+class Multisrc(Attribute):
+    """
+    Multisrc attribute class to manage its properties in the ARENA: Define multiple visual sources applied to an object.
+    Usage: multisrc=Multisrc(...)
+    
+    :param str srcs: A comma-delimited list if URIs, e.g. 'side1.png, side2.png, side3.png, side4.png, side5.png, side6.png' (required). (optional)
+    :param str srcspath: URI, relative or full path of a directory containing srcs, e.g. 'store/users/wiselab/images/dice/' (required). (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/physics.py b/arena/attributes/physics.py
deleted file mode 100644
index 31967540..00000000
--- a/arena/attributes/physics.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from .attribute import Attribute
-
-class Physics(Attribute):
-    """
-    Physics Attribute.
-    Usage: physics=Physics(...) OR dynamic_body=Physics(...)
-    """
-    def __init__(self, type="static", **kwargs):
-        _type = type
-        if _type != "static" and _type != "dynamic":
-            _type = "none"
-        super().__init__(type=_type, **kwargs)
diff --git a/arena/attributes/position.py b/arena/attributes/position.py
index 5015d595..01a78ac4 100644
--- a/arena/attributes/position.py
+++ b/arena/attributes/position.py
@@ -6,8 +6,12 @@
 
 class Position(Attribute):
     """
-    Position Attribute.
+    Position attribute class to manage its properties in the ARENA: 3D object position.
     Usage: position=Position(x,y,z)
+
+    :param float x:  (optional)
+    :param float y:  (optional)
+    :param float z:  (optional)
     """
 
     def __init__(self, x=None, y=None, z=None):
diff --git a/arena/attributes/remote_render.py b/arena/attributes/remote_render.py
new file mode 100644
index 00000000..81dbff01
--- /dev/null
+++ b/arena/attributes/remote_render.py
@@ -0,0 +1,13 @@
+from .attribute import Attribute
+
+
+class RemoteRender(Attribute):
+    """
+    RemoteRender attribute class to manage its properties in the ARENA: Whether or not an object should be remote rendered [Experimental].
+    Usage: remote_render=RemoteRender(...)
+    
+    :param bool enabled: Remote Render this object. Defaults to 'True' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/rotation.py b/arena/attributes/rotation.py
index c92ef73b..20710ac2 100644
--- a/arena/attributes/rotation.py
+++ b/arena/attributes/rotation.py
@@ -6,8 +6,13 @@
 
 class Rotation(Attribute):
     """
-    Rotation Attribute in quaternions or euler.
+    Rotation attribute class to manage its properties in the ARENA: 3D object rotation in quaternion representation; Right-handed coordinate system. Euler degrees are deprecated in wire message format.
     Usage: rotation=Rotation(x,y,z,w) or rotation=Rotation(x,y,z)
+
+    :param float w:  Defaults to '1' (optional)
+    :param float x:  (optional)
+    :param float y:  (optional)
+    :param float z:  (optional)
     """
 
     def __init__(self, x=None, y=None, z=None, w=None):
diff --git a/arena/attributes/scale.py b/arena/attributes/scale.py
index b2f3faf0..4dd3a764 100644
--- a/arena/attributes/scale.py
+++ b/arena/attributes/scale.py
@@ -5,8 +5,12 @@
 
 class Scale(Attribute):
     """
-    Scale Attribute.
+    Scale attribute class to manage its properties in the ARENA: 3D object scale.
     Usage: scale=Scale(x,y,z)
+
+    :param float x:  Defaults to '1' (optional)
+    :param float y:  Defaults to '1' (optional)
+    :param float z:  Defaults to '1' (optional)
     """
 
     def __init__(self, x=None, y=None, z=None):
diff --git a/arena/attributes/shadow.py b/arena/attributes/shadow.py
new file mode 100644
index 00000000..2c7df05a
--- /dev/null
+++ b/arena/attributes/shadow.py
@@ -0,0 +1,14 @@
+from .attribute import Attribute
+
+
+class Shadow(Attribute):
+    """
+    Shadow attribute class to manage its properties in the ARENA: 
+    Usage: shadow=Shadow(...)
+    
+    :param bool cast: Whether the entity casts shadows onto the surrounding scene. (optional)
+    :param bool receive: Whether the entity receives shadows from the surrounding scene. (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/sound.py b/arena/attributes/sound.py
index 2da5e1d8..0478ec8b 100644
--- a/arena/attributes/sound.py
+++ b/arena/attributes/sound.py
@@ -2,8 +2,20 @@
 
 class Sound(Attribute):
     """
-    Sound Attribute.
+    Sound attribute class to manage its properties in the ARENA: The sound component defines the entity as a source of sound or audio. The sound component is positional and is thus affected by the component's position.  More properties at (https://aframe.io/docs/1.5.0/components/sound.html) A-Frame Sound.
     Usage: sound=Sound(...)
+    
+    :param bool autoplay: Whether to automatically play sound once set. (optional)
+    :param str distanceModel: Sound model. Allows [linear, inverse, exponential] Defaults to 'inverse' (optional)
+    :param bool loop: Whether to loop the sound once the sound finishes playing. (optional)
+    :param float maxDistance: Maximum distance between the audio source and the listener, after which the volume is not reduced any further. Defaults to '10000' (optional)
+    :param str on: An event for the entity to listen to before playing sound. Allows [mousedown, mouseup, mouseenter, mouseleave, triggerdown, triggerup, gripdown, gripup, menudown, menuup, systemdown, systemup, trackpaddown, trackpadup] Defaults to 'mousedown' (optional)
+    :param float poolSize: Numbers of simultaneous instances of this sound that can be playing at the same time. Defaults to '1' (optional)
+    :param bool positional: Whether or not the audio is positional (movable). Defaults to 'True' (optional)
+    :param float refDistance: Reference distance for reducing volume as the audio source moves further from the listener. Defaults to '1' (optional)
+    :param float rolloffFactor: Describes how quickly the volume is reduced as the source moves away from the listener. Defaults to '1' (optional)
+    :param str src: URL path to sound file e.g. 'store/users/wiselab/sound/wave.mp3'. (optional)
+    :param float volume: How loud to play the sound. Defaults to '1' (optional)
     """
     def __init__(self, src, positional=False, **kwargs):
         super().__init__(src=src, positional=positional, **kwargs)
diff --git a/arena/attributes/spe_particles.py b/arena/attributes/spe_particles.py
index a92f5bfe..0a98179f 100644
--- a/arena/attributes/spe_particles.py
+++ b/arena/attributes/spe_particles.py
@@ -1,6 +1,78 @@
 from .attribute import Attribute
 
 class SpeParticles(Attribute):
-    """ Maps to "spe-particles".
+    """
+    SpeParticles attribute class to manage its properties in the ARENA: GPU based particle systems in A-Frame.  More properties at (https://github.com/harlyq/aframe-spe-particles-component) A-Frame SPE Particles component.
     Usage: spe_particles=SpeParticles(...)
+    
+    :param dict acceleration: For sphere and disc distributions, only the x axis is used. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param str accelerationDistribution: Distribution of particle acceleration, for disc and sphere, only the x component will be used. if set to NONE use the 'distribution' attribute for accelerationDistribution. Allows [none, box, sphere, disc] Defaults to 'none' (optional)
+    :param dict accelerationSpread: Spread of the particle's acceleration. for sphere and disc distributions, only the x axis is used. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param float activeMultiplier: Multiply the rate of particles emission, if larger than 1 then the particles will be emitted in bursts. note, very large numbers will emit all particles at once. Defaults to '1' (optional)
+    :param bool affectedByFog: If true, the particles are affected by THREE js fog. Defaults to 'True' (optional)
+    :param float alphaTest: Alpha values below the alphaTest threshold are considered invisible. Defaults to '0' (optional)
+    :param list[float] angle: 2D rotation of the particle over the particle's lifetime, max 4 elements. Defaults to '[0]' (optional)
+    :param list[float] angleSpread: Spread in angle over the particle's lifetime, max 4 elements. Defaults to '[0]' (optional)
+    :param str blending: Blending mode, when drawing particles. Allows [no, normal, additive, subtractive, multiply, custom] Defaults to 'normal' (optional)
+    :param list[str] color: Array of colors over the particle's lifetime, max 4 elements. Defaults to '['#fff']' (optional)
+    :param list[dict] colorSpread: Spread to apply to colors, spread an array of vec3 (r g b) with 0 for no spread. note the spread will be re-applied through-out the lifetime of the particle. (optional)
+    :param bool depthTest: If true, don't render a particle's pixels if something is closer in the depth buffer. Defaults to 'True' (optional)
+    :param bool depthWrite: If true, particles write their depth into the depth buffer. this should be false if we use transparent particles. (optional)
+    :param str direction: Make the emitter operate forward or backward in time. Allows [forward, backward] Defaults to 'forward' (optional)
+    :param str distribution: Distribution for particle positions, velocities and acceleration. will be overridden by specific '...Distribution' attributes. Allows [box, sphere, disc] Defaults to 'box' (optional)
+    :param float drag: Apply resistance to moving the particle, 0 is no resistance, 1 is full resistance, particle will stop moving at half of it's maxAge. Defaults to '0' (optional)
+    :param float dragSpread: Spread to apply to the drag attribute. Defaults to '0' (optional)
+    :param float duration: Duration of the emitter (seconds), if less than 0 then continuously emit particles. Defaults to '-1' (optional)
+    :param float emitterScale: Global scaling factor for all particles from the emitter. Defaults to '100' (optional)
+    :param bool enableInEditor: Enable the emitter while the editor is active (i.e. while scene is paused). (optional)
+    :param bool enabled: Enable/disable the emitter. Defaults to 'True' (optional)
+    :param bool frustumCulled: Enable/disable frustum culling. (optional)
+    :param bool hasPerspective: If true, particles will be larger the closer they are to the camera. Defaults to 'True' (optional)
+    :param float maxAge: Maximum age of a particle before reusing. Defaults to '1' (optional)
+    :param float maxAgeSpread: Variance for the 'maxAge' attribute. Defaults to '0' (optional)
+    :param list[float] opacity: Opacity over the particle's lifetime, max 4 elements. Defaults to '[1]' (optional)
+    :param list[float] opacitySpread: Spread in opacity over the particle's lifetime, max 4 elements. Defaults to '[0]' (optional)
+    :param int particleCount: Maximum number of particles for this emitter. Defaults to '100' (optional)
+    :param str positionDistribution: Distribution of particle positions, disc and sphere will use the radius attributes. For box particles emit at 0,0,0, for sphere they emit on the surface of the sphere and for disc on the edge of a 2D disc on the XY plane. Allows [none, box, sphere, disc] Defaults to 'none' (optional)
+    :param dict positionOffset: Fixed offset to the apply to the emitter relative to its parent entity. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param dict positionSpread: Particles are positioned within +- of these local bounds. for sphere and disc distributions only the x axis is used. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param float radius: Radius of the disc or sphere emitter (ignored for box). note radius of 0 will prevent velocity and acceleration if they use a sphere or disc distribution. Defaults to '1' (optional)
+    :param dict radiusScale: Scales the emitter for sphere and disc shapes to form oblongs and ellipses. Defaults to '{'x': 1, 'y': 1, 'z': 1}' (optional)
+    :param bool randomizeAcceleration: If true, re-randomize acceleration when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizeAngle: If true, re-randomize angle when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizeColor: If true, re-randomize colour when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizeDrag: If true, re-randomize drag when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizeOpacity: If true, re-randomize opacity when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizePosition: If true, re-randomize position when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizeRotation: If true, re-randomize rotation when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizeSize: If true, re-randomize size when re-spawning a particle, can incur a performance hit. (optional)
+    :param bool randomizeVelocity: If true, re-randomize velocity when re-spawning a particle, can incur a performance hit. (optional)
+    :param str relative: World relative particles move relative to the world, while local particles move relative to the emitter (i.e. if the emitter moves, all particles move with it). Allows [local, world] Defaults to 'local' (optional)
+    :param float rotation: Rotation in degrees. Defaults to '0' (optional)
+    :param dict rotationAxis: Local axis when using rotation. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param dict rotationAxisSpread: Variance in the axis of rotation. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param float rotationSpread: Rotation variance in degrees. Defaults to '0' (optional)
+    :param bool rotationStatic: If true, the particles are fixed at their initial rotation value. if false, the particle will rotate from 0 to the 'rotation' value. (optional)
+    :param list[float] size: Array of sizes over the particle's lifetime, max 4 elements. Defaults to '[1]' (optional)
+    :param list[float] sizeSpread: Spread in size over the particle's lifetime, max 4 elements. Defaults to '[0]' (optional)
+    :param str texture: Texture to be used for each particle, may be a spritesheet.  Examples: [blob.png, fog.png, square.png, explosion_sheet.png, fireworks_sheet.png], like path 'static/images/textures/blob.png'. (optional)
+    :param int textureFrameCount: Number of frames in the spritesheet, negative numbers default to textureFrames.x * textureFrames.y. Defaults to '-1' (optional)
+    :param int textureFrameLoop: Number of times the spritesheet should be looped over the lifetime of a particle. Defaults to '1' (optional)
+    :param dict textureFrames: X and Y frames for a spritesheet. each particle will transition through every frame of the spritesheet over its lifetime (see textureFramesLoop). Defaults to '{'x': 1, 'y': 1}' (optional)
+    :param bool useTransparency: Should the particles be rendered with transparency? Defaults to 'True' (optional)
+    :param dict velocity: For sphere and disc distributions, only the x axis is used. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param str velocityDistribution: Distribution of particle velocities, for disc and sphere, only the x component will be used. if set to NONE use the 'distribution' attribute for velocityDistribution. Allows [none, box, sphere, disc] Defaults to 'none' (optional)
+    :param dict velocitySpread: Variance for the velocity. Defaults to '{'x': 0, 'y': 0, 'z': 0}' (optional)
+    :param float wiggle: Extra distance the particle moves over its lifetime. Defaults to '0' (optional)
+    :param float wiggleSpread: +- spread for the wiggle attribute. Defaults to '0' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+
+class Particles(SpeParticles):
+    """
+    Alternate name for SpeParticles.
+    Usage: spe_particles=Particles(...)
     """
diff --git a/arena/attributes/static_body.py b/arena/attributes/static_body.py
new file mode 100644
index 00000000..2b3c578f
--- /dev/null
+++ b/arena/attributes/static_body.py
@@ -0,0 +1,16 @@
+from .attribute import Attribute
+
+
+class StaticBody(Attribute):
+    """
+    StaticBody attribute class to manage its properties in the ARENA: A fixed-position or animated object. Other objects may collide with static bodies, but static bodies themselves are unaffected by gravity and collisions.  More properties at (https://github.com/c-frame/aframe-physics-system/blob/master/CannonDriver.md) A-Frame Physics System.
+    Usage: static_body=StaticBody(...)
+    
+    :param str cylinderAxis: Override default axis of bounding cylinder. Requires shape:cylinder. Allows [x, y, z] Defaults to 'y' (optional)
+    :param str shape: Body components will attempt to find an appropriate CANNON.js shape to fit your model. When defining an object you may choose a shape or leave the default, auto. Select a shape carefully, as there are performance implications with different choices. Allows [auto, box, cylinder, sphere, hull, none] Defaults to 'auto' (optional)
+    :param float sphereRadius: Override default radius of bounding sphere. Requires shape:sphere. NaN by default. (optional)
+    :param str type: Define the result of collisions. Dynamic can be moved, Static cannot be moved. Allows [dynamic, static] Defaults to 'static' (optional)
+    """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/attributes/text_input.py b/arena/attributes/text_input.py
deleted file mode 100644
index 7d4d6721..00000000
--- a/arena/attributes/text_input.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from .attribute import Attribute
-
-class TextInput(Attribute):
-    """
-    TextInput Attribute.
-    Usage: textinput=TextInput(...)
-    """
-    def __init__(self,
-                title="Text Input",
-                label="Input text below (max is 140 characters)",
-                placeholder="Type here",
-                on="mousedown",
-                **kwargs):
-        super().__init__(title=title[:140], label=label[:140], placeholder=placeholder[:140], on=on, **kwargs)
diff --git a/arena/attributes/textinput.py b/arena/attributes/textinput.py
new file mode 100644
index 00000000..f3995c54
--- /dev/null
+++ b/arena/attributes/textinput.py
@@ -0,0 +1,29 @@
+from .attribute import Attribute
+
+
+class Textinput(Attribute):
+    """
+    Textinput attribute class to manage its properties in the ARENA: Opens an HTML prompt when clicked. Sends text input as an event on MQTT. Requires click-listener.
+    Usage: textinput=Textinput(...)
+    
+    :param str label: Text prompt label Defaults to 'Input text below (max is 140 characters).' (optional)
+    :param str on: A case-sensitive string representing the event type to listen for, e.g. 'mousedown', 'mouseup'. See <a href='https://developer.mozilla.org/en-US/docs/Web/Events'>Web Events</a> Allows [mousedown, mouseup, mouseenter, mouseleave, triggerdown, triggerup, gripdown, gripup, menudown, menuup, systemdown, systemup, trackpaddown, trackpadup] Defaults to 'mousedown' (optional)
+    :param str placeholder: Text input place holder. Defaults to 'Type here' (optional)
+    :param str title: The prompt title. Defaults to 'Text Input' (optional)
+    """
+
+    def __init__(self,
+                 title="Text Input",
+                 label="Input text below (max is 140 characters)",
+                 placeholder="Type here",
+                 on="mousedown",
+                 **kwargs):
+        super().__init__(title=title[:140], label=label[:140],
+                         placeholder=placeholder[:140], on=on, **kwargs)
+
+
+class TextInput(Textinput):
+    """
+    Alternate name for Textinput.
+    Usage: textinput=TextInput(...)
+    """
diff --git a/arena/attributes/translate.py b/arena/attributes/translate.py
new file mode 100644
index 00000000..5f963a6a
--- /dev/null
+++ b/arena/attributes/translate.py
@@ -0,0 +1,151 @@
+# This file is auto-generated from github.com/arenaxr/arena-schema, changes here may be overwritten.
+
+from .animation import *
+from .animation_mixer import *
+from .armarker import *
+from .attribution import *
+from .blip import *
+from .box_collision_listener import *
+from .click_listener import *
+from .dynamic_body import *
+from .gltf_model_lod import *
+from .gltf_morph import *
+from .goto_landmark import *
+from .goto_url import *
+from .impulse import *
+from .jitsi_video import *
+from .landmark import *
+from .material import *
+from .material_extras import *
+from .model_update import *
+from .multisrc import *
+from .position import *
+from .remote_render import *
+from .rotation import *
+from .scale import *
+from .shadow import *
+from .sound import *
+from .spe_particles import *
+from .static_body import *
+from .textinput import *
+from .video_control import *
+
+ATTRIBUTE_KEYWORD_TRANSLATION = {
+    "physics": "dynamic-body",  # backward-compatibility
+    "clickable": "click-listener",  # backward-compatibility
+    "animation": "animation",
+    "animation_mixer": "animation-mixer",
+    "armarker": "armarker",
+    "attribution": "attribution",
+    "blip": "blip",
+    "box_collision_listener": "box-collision-listener",
+    "buffer": "buffer",
+    "click_listener": "click-listener",
+    "collision_listener": "collision-listener",
+    "dynamic_body": "dynamic-body",
+    "gltf_model_lod": "gltf-model-lod",
+    "gltf_morph": "gltf-morph",
+    "goto_landmark": "goto-landmark",
+    "goto_url": "goto-url",
+    "hide_on_enter_ar": "hide-on-enter-ar",
+    "hide_on_enter_vr": "hide-on-enter-vr",
+    "impulse": "impulse",
+    "jitsi_video": "jitsi-video",
+    "landmark": "landmark",
+    "look_at": "look-at",
+    "material": "material",
+    "material_extras": "material-extras",
+    "modelUpdate": "modelUpdate",
+    "multisrc": "multisrc",
+    "parent": "parent",
+    "position": "position",
+    "remote_render": "remote-render",
+    "rotation": "rotation",
+    "scale": "scale",
+    "screenshareable": "screenshareable",
+    "shadow": "shadow",
+    "show_on_enter_ar": "show-on-enter-ar",
+    "show_on_enter_vr": "show-on-enter-vr",
+    "skipCache": "skipCache",
+    "sound": "sound",
+    "spe_particles": "spe-particles",
+    "static_body": "static-body",
+    "textinput": "textinput",
+    "url": "url",
+    "video_control": "video-control",
+}
+
+KEYWORD_ATTRIBUTE_TRANSLATION = {
+    "animation": "animation",
+    "animation-mixer": "animation_mixer",
+    "armarker": "armarker",
+    "attribution": "attribution",
+    "blip": "blip",
+    "box-collision-listener": "box_collision_listener",
+    "buffer": "buffer",
+    "click-listener": "click_listener",
+    "collision-listener": "collision_listener",
+    "dynamic-body": "dynamic_body",
+    "gltf-model-lod": "gltf_model_lod",
+    "gltf-morph": "gltf_morph",
+    "goto-landmark": "goto_landmark",
+    "goto-url": "goto_url",
+    "hide-on-enter-ar": "hide_on_enter_ar",
+    "hide-on-enter-vr": "hide_on_enter_vr",
+    "impulse": "impulse",
+    "jitsi-video": "jitsi_video",
+    "landmark": "landmark",
+    "look-at": "look_at",
+    "material": "material",
+    "material-extras": "material_extras",
+    "modelUpdate": "modelUpdate",
+    "multisrc": "multisrc",
+    "parent": "parent",
+    "position": "position",
+    "remote-render": "remote_render",
+    "rotation": "rotation",
+    "scale": "scale",
+    "screenshareable": "screenshareable",
+    "shadow": "shadow",
+    "show-on-enter-ar": "show_on_enter_ar",
+    "show-on-enter-vr": "show_on_enter_vr",
+    "skipCache": "skipCache",
+    "sound": "sound",
+    "spe-particles": "spe_particles",
+    "static-body": "static_body",
+    "textinput": "textinput",
+    "url": "url",
+    "video-control": "video_control",
+}
+
+ATTRIBUTE_CLASS_TRANSLATION = {
+    "animation": Animation,
+    "animation_mixer": AnimationMixer,
+    "armarker": Armarker,
+    "attribution": Attribution,
+    "blip": Blip,
+    "box_collision_listener": BoxCollisionListener,
+    "click_listener": ClickListener,
+    "dynamic_body": DynamicBody,
+    "gltf_model_lod": GltfModelLod,
+    "gltf_morph": GltfMorph,
+    "goto_landmark": GotoLandmark,
+    "goto_url": GotoUrl,
+    "impulse": Impulse,
+    "jitsi_video": JitsiVideo,
+    "landmark": Landmark,
+    "material": Material,
+    "material_extras": MaterialExtras,
+    "modelUpdate": ModelUpdate,
+    "multisrc": Multisrc,
+    "position": Position,
+    "remote_render": RemoteRender,
+    "rotation": Rotation,
+    "scale": Scale,
+    "shadow": Shadow,
+    "sound": Sound,
+    "spe_particles": SpeParticles,
+    "static_body": StaticBody,
+    "textinput": Textinput,
+    "video_control": VideoControl,
+}
diff --git a/arena/attributes/video_control.py b/arena/attributes/video_control.py
index 87547b9d..6270d649 100644
--- a/arena/attributes/video_control.py
+++ b/arena/attributes/video_control.py
@@ -2,6 +2,18 @@
 
 class VideoControl(Attribute):
     """
-    VideoControl Attribute.
+    VideoControl attribute class to manage its properties in the ARENA: Video Control.
     Usage: video_control=VideoControl(...)
+    
+    :param bool anyone_clicks: Responds to clicks from any user. Defaults to 'True' (optional)
+    :param bool autoplay: Video starts playing automatically. (optional)
+    :param bool cleanup: Automatically remove HTML5 video and img assets from DOM on object removal. Defaults to 'True' (optional)
+    :param str frame_object: URL of a thumbnail image, e.g. 'store/users/wiselab/images/conix-face-white.jpg'. (optional)
+    :param bool video_loop: Video automatically loops. Defaults to 'True' (optional)
+    :param str video_object: Name of object where to put the video, e.g. 'square_vid6'. (optional)
+    :param str video_path: URL of the video file, e.g. 'store/users/wiselab/videos/kungfu.mp4'. (optional)
+    :param float volume: Video sound volume. Defaults to '1' (optional)
     """
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
diff --git a/arena/device.py b/arena/device.py
index c82e6949..f82e55bd 100644
--- a/arena/device.py
+++ b/arena/device.py
@@ -5,6 +5,10 @@
 
 from .arena_mqtt import ArenaMQTT
 
+from .env import (
+    DEVICE,
+    _get_env
+)
 
 class Device(ArenaMQTT):
     """
@@ -39,8 +43,8 @@ def __init__(
             if self.args["debug"]:
                 debug = self.args["debug"]
 
-        if os.environ.get("DEVICE"):
-            self.device = os.environ["DEVICE"]
+        if os.environ.get(DEVICE):
+            self.device = _get_env(DEVICE)
             print(f"Using Device from 'DEVICE' env variable: {self.device}")
         elif "device" in kwargs and kwargs["device"]:
             if re.search("/", kwargs["device"]):
@@ -79,3 +83,6 @@ def publish(self, topic, payload_obj):
         self.mqttc.publish(topic, payload, qos=0)
         if self.debug: print("[publish]", topic, payload)
         return payload
+    
+    def on_publish(self, client, userdata, mid):
+        pass
\ No newline at end of file
diff --git a/arena/env.py b/arena/env.py
new file mode 100644
index 00000000..b4dd862c
--- /dev/null
+++ b/arena/env.py
@@ -0,0 +1,150 @@
+# Environment variables definitions
+# When applicable, variable defaults are defined by ENV_DEFAULTS
+#
+import sys 
+import os
+
+REALM = "REALM"
+"""
+.. envvar:: REALM
+
+The :envvar:`REALM` defines the ARENA Realm to listen to.
+After connecting, the library listens to a scene topic as follows:
+`{REALM}/s/{NAMESPACE}/{SCENE}`.
+This variable overrides arguments passed in the scene's constructor.
+"""
+
+SCENE = "SCENE"
+"""
+.. envvar:: SCENE
+
+The :envvar:`SCENE` defines ARENA Scene to listen to.
+After connecting, the library listens to a scene topic as follows:
+`{REALM}/s/{NAMESPACE}/{SCENE}`.
+This variable overrides arguments passed in the scene's constructor.
+"""
+
+NAMESPACE = "NAMESPACE"
+"""
+.. envvar:: NAMESPACE
+
+The :envvar:`NAMESPACE` defines ARENA Namespace to listen to.
+After connecting, the library listens to a scene topic as follows:
+`{REALM}/s/{NAMESPACE}/{SCENE}`.
+This variable overrides arguments passed in the scene's constructor.
+"""
+
+ARENA_USERNAME = "ARENA_USERNAME"
+"""
+.. envvar:: ARENA_USERNAME
+
+The :envvar:`ARENA_USERNAME` defines username used to authenticate.
+If undefined, will try to use local authentication information previously saved.
+"""
+
+ARENA_PASSWORD = "ARENA_PASSWORD"
+"""
+.. envvar:: ARENA_PASSWORD
+
+The :envvar:`ARENA_PASSWORD` defines password used to authenticate.
+If undefined, will try to use local authentication information previously saved.
+"""
+
+MQTTH = "MQTTH"
+"""
+.. envvar:: MQTTH
+
+The :envvar:`MQTTH` defines the MQTT host used by the library.
+This variable allows to use a broker different from the host argument passed to the 
+scene constructor
+"""
+
+DEVICE = "DEVICE"
+"""
+.. envvar:: DEVICE
+
+The :envvar:`DEVICE` defines the name of a device, to publish and listen to. 
+After connecting, the library listens to device topic as follows:
+`{REALM}/d/{NAMESPACE}/{SCENE}`.
+
+This variable overrides arguments passed in the command line.
+"""
+
+PROGRAM_OBJECT_ID = "PROGRAM_OBJECT_ID"
+"""
+.. envvar:: PROGRAM_OBJECT_ID
+
+The :envvar:`PROGRAM_OBJECT_ID` indicates the object id in ARENA persist for this program.
+This is passed by the runtime and used to identify the program object that represents the currently running program.
+"""
+
+ENABLE_INTERPRETER = "ENABLE_INTERPRETER"
+"""
+.. envvar:: ENABLE_INTERPRETER
+
+The :envvar:`ENABLE_INTERPRETER` enables the a simple command line interpreter that
+can be used to inspect library/program state. Set this variable with a value of 
+`true`, `1` or `t` (case insensitive) to enable the interpreter.
+
+Default: 'false'
+"""
+
+ARENA_TELEMETRY = "ARENA_TELEMETRY"
+"""
+.. envvar:: ARENA_TELEMETRY
+
+The :envvar:`ARENA_TELEMETRY` environment variable enables the library's telemetry to generate 
+traces, metrics, and logs. Set this variable with a value of `otlp`, `mqtt` or `console` (case insensitive) 
+to enable telemetry using OpenTelemetry (OTEL) and its Protocol (OTLP), send JSON OTEL spans to MQTT, or to the console.
+"""
+
+OTLP_ENDPOINT = "OTLP_ENDPOINT"
+"""
+.. envvar:: OTLP_ENDPOINT
+
+The :envvar:`OTLP_ENDPOINT` environment variable is used when OTLP telemetry is enabled (`ARENA_TELEMETRY=otlp`) to define 
+the telemtry endpoint.
+
+Our implementation uses OpenTelemetry (OTEL) and its Protocol (OTLP) for encoding and transport.
+
+Default: "http://localhost:4317"
+"""
+
+OTEL_LOG_LEVEL = "OTEL_LOG_LEVEL"
+"""
+.. envvar:: OTEL_LOG_LEVEL
+
+The :envvar:`OTEL_LOG_LEVEL` environment variable sets the log level used by the logger 
+implementation (ArenaTelemetry) using OpenTelemetry (OTEL). 
+Default: "info". 
+"""
+
+PROGRAM_STATS_UPDATE_INTERVAL_MS = "PROGRAM_STATS_UPDATE_INTERVAL_MS"
+"""
+.. envvar:: PROGRAM_STATS_UPDATE_INTERVAL_MS
+
+The :envvar:`PROGRAM_STATS_UPDATE_INTERVAL_MS` environment variable defines how often program
+stats are published
+
+Default: 5000. 
+"""
+
+""" Environment variables default values """
+ENV_DEFAULTS = {
+  ENABLE_INTERPRETER:   'false',
+  OTLP_ENDPOINT:        'http://localhost:4317',
+  OTEL_LOG_LEVEL:       'info',
+  PROGRAM_STATS_UPDATE_INTERVAL_MS: 5000
+}
+
+def _get_env(env_var):
+  """ Get value of env variable with default defined by ENV_DEFAULTS; None if not defined in ENV_DEFAULTS """
+  return os.environ.get(env_var, ENV_DEFAULTS.get(env_var))
+
+def _get_arena_env():
+  """Get all variables defined in this module; skip credentials, private data and imports"""
+  env = {}
+  skip = ( ARENA_PASSWORD, ARENA_USERNAME, 'os', 'sys' )
+  for key in [ v for v in dir(sys.modules[__name__]) if not v.startswith('_') and v not in skip]:
+    env[key] = os.environ.get(key)
+  return env
diff --git a/arena/objects/__init__.py b/arena/objects/__init__.py
index 69b688bb..f578ffec 100644
--- a/arena/objects/__init__.py
+++ b/arena/objects/__init__.py
@@ -1,53 +1,76 @@
-from .arena_object import Object
-from .box import Box
-from .camera import Camera
-from .circle import Circle
-from .cone import Cone
-from .cylinder import Cylinder
-from .dodecahedron import Dodecahedron
-from .gltf import GLTF
-from .icosahedron import Icosahedron
-from .hands import HandLeft, HandRight
-from .image import Image
-from .light import Light
-from .line import Line
-from .octahedron import Octahedron
-from .plane import Plane
-from .ring import Ring
-from .sphere import Sphere
-from .tetrahedron import Tetrahedron
-from .text import Text
-from .thickline import ThickLine
-from .torus import Torus
-from .torus_knot import TorusKnot
-from .triangle import Triangle
-from .ui import *
+# This file is auto-generated from github.com/arenaxr/arena-schema, changes here may be overwritten.
+
+from .arena_object import *
+from .arenaui_button_panel import *
+from .arenaui_card import *
+from .arenaui_prompt import *
+from .box import *
+from .camera import *
+from .capsule import *
+from .circle import *
+from .cone import *
+from .cylinder import *
+from .dodecahedron import *
+from .entity import *
+from .gaussian_splatting import *
+from .gltf_model import *
+from .hand_left import *
+from .hand_right import *
+from .icosahedron import *
+from .image import *
+from .light import *
+from .line import *
+from .ocean import *
+from .octahedron import *
+from .pcd_model import *
+from .plane import *
+from .ring import *
+from .roundedbox import *
+from .sphere import *
+from .tetrahedron import *
+from .text import *
+from .thickline import *
+from .threejs_scene import *
+from .torus import *
+from .torus_knot import *
+from .triangle import *
+from .videosphere import *
+from .program import *
 
 OBJECT_TYPE_MAP = {
-    Box.object_type:          Box,
-    Camera.object_type:       Camera,
-    Circle.object_type:       Circle,
-    Cone.object_type:         Cone,
-    Box.object_type:          Box,
-    Cylinder.object_type:     Cylinder,
+    Object.object_type: Object,
+    ArenauiButtonPanel.object_type: ArenauiButtonPanel,
+    ArenauiCard.object_type: ArenauiCard,
+    ArenauiPrompt.object_type: ArenauiPrompt,
+    Box.object_type: Box,
+    Camera.object_type: Camera,
+    Capsule.object_type: Capsule,
+    Circle.object_type: Circle,
+    Cone.object_type: Cone,
+    Cylinder.object_type: Cylinder,
     Dodecahedron.object_type: Dodecahedron,
-    Object.object_type:       Object,
-    GLTF.object_type:         GLTF,
-    Icosahedron.object_type:  Icosahedron,
-    Image.object_type:        Image,
-    HandLeft.object_type:     HandLeft,
-    HandRight.object_type:    HandRight,
-    Light.object_type:        Light,
-    Line.object_type:         Line,
-    Object.object_type:       Object,
-    Octahedron.object_type:   Octahedron,
-    Plane.object_type:        Plane,
-    Ring.object_type:         Ring,
-    Sphere.object_type:       Sphere,
-    Tetrahedron.object_type:  Tetrahedron,
-    Text.object_type:         Text,
-    ThickLine.object_type:    ThickLine,
-    Torus.object_type:        Torus,
-    TorusKnot.object_type:    TorusKnot,
-    Triangle.object_type:     Triangle,
-}
+    Entity.object_type: Entity,
+    GaussianSplatting.object_type: GaussianSplatting,
+    GltfModel.object_type: GltfModel,
+    HandLeft.object_type: HandLeft,
+    HandRight.object_type: HandRight,
+    Icosahedron.object_type: Icosahedron,
+    Image.object_type: Image,
+    Light.object_type: Light,
+    Line.object_type: Line,
+    Ocean.object_type: Ocean,
+    Octahedron.object_type: Octahedron,
+    PcdModel.object_type: PcdModel,
+    Plane.object_type: Plane,
+    Ring.object_type: Ring,
+    Roundedbox.object_type: Roundedbox,
+    Sphere.object_type: Sphere,
+    Tetrahedron.object_type: Tetrahedron,
+    Text.object_type: Text,
+    Thickline.object_type: Thickline,
+    ThreejsScene.object_type: ThreejsScene,
+    Torus.object_type: Torus,
+    TorusKnot.object_type: TorusKnot,
+    Triangle.object_type: Triangle,
+    Videosphere.object_type: Videosphere,
+}
\ No newline at end of file
diff --git a/arena/objects/arena_object.py b/arena/objects/arena_object.py
index a18b8837..9bc92227 100644
--- a/arena/objects/arena_object.py
+++ b/arena/objects/arena_object.py
@@ -1,7 +1,9 @@
+import uuid
+
 from ..base_object import *
-from ..attributes import Animation, Data, Position, Rotation, Scale, Color
+from ..attributes import Animation, Data, Position, Rotation, Scale, ATTRIBUTE_KEYWORD_TRANSLATION
 from ..utils import *
-import uuid
+
 
 class Object(BaseObject):
     """
@@ -153,45 +155,26 @@ def json(self, **kwargs):
                 # always publish quaternions on wire format to avoid persist euler->quat merges
                 json_data["rotation"] = rot.quaternion
 
-            # handle special case where "physics" should be "dynamic-body"
-            elif "physics" == k or "dynamic_body" == k:
-                json_data["dynamic-body"] = v
-
-            # handle special case where "clickable" should be "click-listener"
-            elif "clickable" == k or "click_listener" == k:
-                json_data["click-listener"] = v
-
-            # remove underscores from specific keys
-            elif "goto_url" == k:
-                json_data["goto-url"] = v
-
-            elif "jitsi_video" == k:
-                json_data["jitsi-video"] = v
-
-            elif "collision_listener" == k:
-                json_data["collision-listener"] = v
-
-            elif "animation_mixer" == k:
-                json_data["animation-mixer"] = v
-
-            elif "video_control" == k:
-                json_data["video-control"] = v
-
             elif "look_at" == k:
                 if isinstance(v, str):
                     json_data["look-at"] = v
                 elif isinstance(v, Object):
                     json_data["look-at"] = v.object_id
 
-            elif "spe_particles" == k:
-                json_data["spe-particles"] = v
-
             # for animation, replace "start" and "end" with "from" and "to"
             elif isinstance(k, str) and "animation" == k[:len("animation")]:
                 animation = vars(v).copy()
                 Utils.dict_key_replace(animation, "start", "from")
                 Utils.dict_key_replace(animation, "end", "to")
                 json_data[k] = animation
+
+            # Translate and remove underscores from any other keys.
+            # Must be done last since ATTRIBUTE_KEYWORD_TRANSLATION contains all attributes,
+            # which may interfere with special casing above from, say "rotation" for example.
+            elif k in ATTRIBUTE_KEYWORD_TRANSLATION:
+                json_data[ATTRIBUTE_KEYWORD_TRANSLATION[k]] = v
+
+            # allow: likely unknown/misspelled attribute to this library version
             else:
                 json_data[k] = v
 
@@ -215,8 +198,9 @@ def add(cls, obj):
     def remove(cls, obj):
         object_id = obj.object_id
         del Object.all_objects[object_id]
-        for task in obj.delayed_prop_tasks.values():  # Cancel all pending tasks
-            task.cancel()
+        if (hasattr(obj, "delayed_prop_tasks")):
+            for task in obj.delayed_prop_tasks.values():  # Cancel all pending tasks
+                task.cancel()
 
     @classmethod
     def exists(cls, object_id):
diff --git a/arena/objects/arenaui_button_panel.py b/arena/objects/arenaui_button_panel.py
new file mode 100644
index 00000000..9402ed8d
--- /dev/null
+++ b/arena/objects/arenaui_button_panel.py
@@ -0,0 +1,56 @@
+from .arena_object import Object
+
+class ArenauiButtonPanel(Object):
+    """
+    ArenauiButtonPanel object class to manage its properties in the ARENA: ARENAUI element which displays a vertical or horizontal panel of buttons.
+    
+    :param list[dict] buttons: Buttons Defaults to '[{'name': 'Option 1'}, {'name': 'Option 2'}]' (optional)
+    :param str font: Font to use for button text. Allows [Roboto, Roboto-Mono] Defaults to 'Roboto' (optional)
+    :param str theme: Color Theme Allows [light, dark] Defaults to 'light' (optional)
+    :param str title: Title to display above buttons (optional). (optional)
+    :param bool vertical: Vertical button layout (optional)
+    """
+    object_type = "arenaui-button-panel"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=ButtonPanel.object_type, **kwargs)
+
+    def json_preprocess(self, **kwargs):
+        # kwargs are for additional param to add to json, like "action":"create"
+        skipped_keys = [
+            "evt_handler",
+            "update_handler",
+            "animations",
+            "delayed_prop_tasks",
+        ]
+        json_payload = {k: v for k, v in vars(
+            self).items() if k not in skipped_keys}
+        json_payload["data"]["buttons"] = [
+            vars(button) if hasattr(button, "__dict__") else button
+            for button in json_payload["data"]["buttons"]
+        ]
+        json_payload.update(kwargs)
+        return json_payload
+
+
+class ButtonPanel(ArenauiButtonPanel):
+    """
+    Alternate name for ArenauiButtonPanel.
+    """
+
+
+class Button:
+    """
+    Buttons of a ButtonPanel in the ARENA UI.
+
+    :param str name: Button Name
+    :param str img: Image URl for image buttons (optional)
+    :param float size: Size of image button (optional)
+    :param float height: Height of image button, overrides size (optional)
+    :param float: Width of image button, overrides size (optional)
+    :param float borderRadius: Border radius of image button (optional)
+    """
+
+    def __init__(self, name="Button", **kwargs):
+        self.name = name
+        self.__dict__.update(kwargs)
diff --git a/arena/objects/arenaui_card.py b/arena/objects/arenaui_card.py
new file mode 100644
index 00000000..5739a134
--- /dev/null
+++ b/arena/objects/arenaui_card.py
@@ -0,0 +1,30 @@
+from .arena_object import Object
+
+class ArenauiCard(Object):
+    """
+    ArenauiCard object class to manage its properties in the ARENA: ARENAUI element which displays text and optionally an image.
+    
+    :param str body: This is the text body of the card. (optional)
+    :param str bodyAlign: Body Text Alignment Allows [left, center, right, justify] Defaults to 'left' (optional)
+    :param bool closeButton: Show close button (optional)
+    :param str font: Font to use for button text. Allows [Roboto, Roboto-Mono] Defaults to 'Roboto' (optional)
+    :param float fontSize: Font Size Defaults to '0.035' (optional)
+    :param str img: This image will be embedded alongside the body text. (optional)
+    :param str imgCaption: This will caption the image. (optional)
+    :param str imgDirection: Image Direction Allows [left, right] Defaults to 'right' (optional)
+    :param str imgSize: Image sizing Allows [cover, contain, stretch] Defaults to 'cover' (optional)
+    :param float textImageRatio: Text to Image Width Ratio Defaults to '0.5' (optional)
+    :param str theme: Color Theme Allows [light, dark] Defaults to 'light' (optional)
+    :param str title: Title (optional)
+    :param float widthScale: Width scale multiplier Defaults to '1' (optional)
+    """
+    object_type = "arenaui-card"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=ArenauiCard.object_type, **kwargs)
+
+
+class Card(ArenauiCard):
+    """
+    Alternate name for ArenauiCard.
+    """
diff --git a/arena/objects/arenaui_prompt.py b/arena/objects/arenaui_prompt.py
new file mode 100644
index 00000000..a2d48ec7
--- /dev/null
+++ b/arena/objects/arenaui_prompt.py
@@ -0,0 +1,23 @@
+from .arena_object import Object
+
+class ArenauiPrompt(Object):
+    """
+    ArenauiPrompt object class to manage its properties in the ARENA: ARENAUI element which displays prompt with button actions.
+    
+    :param list[str] buttons: Buttons Defaults to '['Confirm', 'Cancel']' (optional)
+    :param str description: Description Defaults to 'This is a prompt. Please confirm or cancel.' (optional)
+    :param str font: Font to use for button text. Allows [Roboto, Roboto-Mono] Defaults to 'Roboto' (optional)
+    :param str theme: Color Theme Allows [light, dark] Defaults to 'light' (optional)
+    :param str title: Title Defaults to 'Prompt' (optional)
+    :param float width: Override width Defaults to '1.5' (optional)
+    """
+    object_type = "arenaui-prompt"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=ArenauiPrompt.object_type, **kwargs)
+
+
+class Prompt(ArenauiPrompt):
+    """
+    Alternate name for ArenauiPrompt.
+    """
diff --git a/arena/objects/box.py b/arena/objects/box.py
index 750faae4..d7379037 100644
--- a/arena/objects/box.py
+++ b/arena/objects/box.py
@@ -2,13 +2,21 @@
 
 class Box(Object):
     """
-    Class for Box in the ARENA.
+    Box object class to manage its properties in the ARENA: Box Geometry.
+    
+    :param float depth: depth Defaults to '1' (optional)
+    :param float height: height Defaults to '1' (optional)
+    :param int segmentsDepth: segments depth Defaults to '1' (optional)
+    :param int segmentsHeight: segments height Defaults to '1' (optional)
+    :param int segmentsWidth: segments width Defaults to '1' (optional)
+    :param float width: width Defaults to '1' (optional)
     """
     object_type = "box"
 
     def __init__(self, **kwargs):
         super().__init__(object_type=Box.object_type, **kwargs)
 
+
 class Cube(Object):
     """
     Class for Cube in the ARENA.
diff --git a/arena/objects/camera.py b/arena/objects/camera.py
index e7e3ca3d..4e7890b8 100644
--- a/arena/objects/camera.py
+++ b/arena/objects/camera.py
@@ -4,7 +4,9 @@
 
 class Camera(Object):
     """
-    Class for Camera in the ARENA.
+    Camera object class to manage its properties in the ARENA: Camera is the pose and arena-user component data representing a user avatar.
+    
+    :param dict arena_user:  (optional)
     """
 
     object_type = "camera"
diff --git a/arena/objects/capsule.py b/arena/objects/capsule.py
new file mode 100644
index 00000000..9222b94b
--- /dev/null
+++ b/arena/objects/capsule.py
@@ -0,0 +1,15 @@
+from .arena_object import Object
+
+class Capsule(Object):
+    """
+    Capsule object class to manage its properties in the ARENA: Capsule Geometry.
+    
+    :param float length: length Defaults to '1' (optional)
+    :param float radius: radius Defaults to '1' (optional)
+    :param int segmentsCap: segments capsule Defaults to '18' (optional)
+    :param int segmentsRadial: segments radial Defaults to '36' (optional)
+    """
+    object_type = "capsule"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=Capsule.object_type, **kwargs)
diff --git a/arena/objects/circle.py b/arena/objects/circle.py
index d80205e4..4f39f5f3 100644
--- a/arena/objects/circle.py
+++ b/arena/objects/circle.py
@@ -2,7 +2,12 @@
 
 class Circle(Object):
     """
-    Class for Circle in the ARENA.
+    Circle object class to manage its properties in the ARENA: Circle Geometry.
+    
+    :param float radius: radius Defaults to '1' (optional)
+    :param int segments: segments Defaults to '32' (optional)
+    :param float thetaLength: theta length Defaults to '360' (optional)
+    :param float thetaStart: theta start (optional)
     """
     object_type = "circle"
 
diff --git a/arena/objects/cone.py b/arena/objects/cone.py
index 4a37664d..27a244d2 100644
--- a/arena/objects/cone.py
+++ b/arena/objects/cone.py
@@ -2,7 +2,16 @@
 
 class Cone(Object):
     """
-    Class for Cone in the ARENA.
+    Cone object class to manage its properties in the ARENA: Cone Geometry.
+    
+    :param float height: height Defaults to '1' (optional)
+    :param bool openEnded: open ended (optional)
+    :param float radiusBottom: radius bottom Defaults to '1' (optional)
+    :param float radiusTop: radius top Defaults to '0.01' (optional)
+    :param int segmentsHeight: segments height Defaults to '18' (optional)
+    :param int segmentsRadial: segments radial Defaults to '36' (optional)
+    :param float thetaLength: theta length Defaults to '360' (optional)
+    :param float thetaStart: theta start (optional)
     """
     object_type = "cone"
 
diff --git a/arena/objects/cylinder.py b/arena/objects/cylinder.py
index 89dbfef6..eec09633 100644
--- a/arena/objects/cylinder.py
+++ b/arena/objects/cylinder.py
@@ -2,8 +2,17 @@
 
 class Cylinder(Object):
     """
-    Class for Cylinder in the ARENA.
+    Cylinder object class to manage its properties in the ARENA: Cylinder Geometry.
+    
+    :param float height: height Defaults to '1' (optional)
+    :param bool openEnded: open ended (optional)
+    :param float radius: radius Defaults to '1' (optional)
+    :param int segmentsHeight: segments height Defaults to '18' (optional)
+    :param int segmentsRadial: segments radial Defaults to '36' (optional)
+    :param float thetaLength: theta length Defaults to '360' (optional)
+    :param float thetaStart: theta start (optional)
     """
     object_type = "cylinder"
+
     def __init__(self, **kwargs):
         super().__init__(object_type=Cylinder.object_type, **kwargs)
diff --git a/arena/objects/dodecahedron.py b/arena/objects/dodecahedron.py
index a0eebbdb..1c678313 100644
--- a/arena/objects/dodecahedron.py
+++ b/arena/objects/dodecahedron.py
@@ -2,7 +2,10 @@
 
 class Dodecahedron(Object):
     """
-    Class for Dodecahedron in the ARENA.
+    Dodecahedron object class to manage its properties in the ARENA: Dodecahedron Geometry.
+    
+    :param int detail: detail (optional)
+    :param float radius: radius Defaults to '1' (optional)
     """
     object_type = "dodecahedron"
 
diff --git a/arena/objects/entity.py b/arena/objects/entity.py
new file mode 100644
index 00000000..226641a9
--- /dev/null
+++ b/arena/objects/entity.py
@@ -0,0 +1,13 @@
+from .arena_object import Object
+
+class Entity(Object):
+    """
+    Entity object class to manage its properties in the ARENA: Entities are the base of all objects in the scene. Entities are containers into which components can be attached.
+    
+    :param dict geometry:  (optional)
+    :param dict panel:  (optional)
+    """
+    object_type = "entity"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=Entity.object_type, **kwargs)
diff --git a/arena/objects/gaussian_splatting.py b/arena/objects/gaussian_splatting.py
new file mode 100644
index 00000000..328f3cb5
--- /dev/null
+++ b/arena/objects/gaussian_splatting.py
@@ -0,0 +1,15 @@
+from .arena_object import Object
+
+class GaussianSplatting(Object):
+    """
+    GaussianSplatting object class to manage its properties in the ARENA: Load a 3D Gaussian Splat for Real-Time Radiance Field Rendering.  More information : (https://github.com/quadjr/aframe-gaussian-splatting) A-Frame Gaussian Splatting. See guidance to store paths under (https://docs.arenaxr.org/content/interface/filestore.html) ARENA File Store, CDN, or DropBox.
+    
+    :param str cutoutEntity: Selector to a box primitive that uses scale and position to define the bounds of splat points to render. (optional)
+    :param float pixelRatio: Pixel ratio for rendering. Reducing the value decreases the resolution and improves performance. If a negative value is set, the device's native value will be applied. Defaults to '1.0' (optional)
+    :param str src: Use File Store paths under 'store/users/username', see CDN and other storage options in the description above. (optional)
+    :param float xrPixelRatio: Same as pixelRatio. Applied to XR devices. Defaults to '0.5' (optional)
+    """
+    object_type = "gaussian_splatting"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=GaussianSplatting.object_type, **kwargs)
diff --git a/arena/objects/gltf.py b/arena/objects/gltf_model.py
similarity index 64%
rename from arena/objects/gltf.py
rename to arena/objects/gltf_model.py
index 5ed67e29..3a89228d 100644
--- a/arena/objects/gltf.py
+++ b/arena/objects/gltf_model.py
@@ -2,9 +2,11 @@
 from .arena_object import Object
 from ..attributes import Morph
 
-class GLTF(Object):
+class GltfModel(Object):
     """
-    Class for GLTF Models in the ARENA.
+    GltfModel object class to manage its properties in the ARENA: Load a GLTF model.   Besides applying standard rotation and position attributes to the center-point of the GLTF model, the individual child components can also be manually manipulated. See format details in the `modelUpdate` data attribute. See guidance to store paths under (https://docs.arenaxr.org/content/interface/filestore.html) ARENA File Store, CDN, or DropBox.
+    
+    :param str url: Use File Store paths under 'store/users/username', see CDN and other storage options in the description above. (optional)
     """
     object_type = "gltf-model"
 
@@ -40,7 +42,14 @@ def json_postprocess(self, json_payload, json_data):
         for i,morph in enumerate(self.morphs.values()):
             json_data[f"gltf-morph__{i}"] = vars(morph)
 
-class Model(GLTF):
+
+class GLTF(GltfModel):
+    """
+    Another name for GltfModel.
+    """
+
+
+class Model(GltfModel):
     """
-    Another name for GLTF.
+    Another name for GltfModel.
     """
diff --git a/arena/objects/hand_left.py b/arena/objects/hand_left.py
new file mode 100644
index 00000000..6685f9d2
--- /dev/null
+++ b/arena/objects/hand_left.py
@@ -0,0 +1,14 @@
+from .arena_object import Object
+
+class HandLeft(Object):
+    """
+    HandLeft object class to manage its properties in the ARENA: Hand is the (left or right) hand metadata pose and controller type of the user avatar.
+    
+    :param str dep: Camera object_id this hand belongs to. (optional)
+    :param str url: Path to user avatar hand model. Defaults to 'static/models/hands/valve_index_left.gltf' (optional)
+    """
+    object_type = "handLeft"
+
+    def __init__(self,**kwargs):
+        self.camera = None
+        super().__init__(object_type=HandLeft.object_type, **kwargs)
diff --git a/arena/objects/hand_right.py b/arena/objects/hand_right.py
new file mode 100644
index 00000000..3ac39af4
--- /dev/null
+++ b/arena/objects/hand_right.py
@@ -0,0 +1,14 @@
+from .arena_object import Object
+
+class HandRight(Object):
+    """
+    HandRight object class to manage its properties in the ARENA: Hand is the (left or right) hand metadata pose and controller type of the user avatar.
+    
+    :param str dep: Camera object_id this hand belongs to. (optional)
+    :param str url: Path to user avatar hand model. Defaults to 'static/models/hands/valve_index_left.gltf' (optional)
+    """
+    object_type = "handRight"
+
+    def __init__(self,**kwargs):
+        self.camera = None
+        super().__init__(object_type=HandRight.object_type, **kwargs)
diff --git a/arena/objects/hands.py b/arena/objects/hands.py
deleted file mode 100644
index 414d3d70..00000000
--- a/arena/objects/hands.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from .arena_object import Object
-
-class HandLeft(Object):
-    """
-    Class for Left Hands/Controllers in the ARENA.
-    """
-    object_type = "handLeft"
-
-    def __init__(self,**kwargs):
-        self.camera = None
-        super().__init__(object_type=HandLeft.object_type, **kwargs)
-
-class HandRight(Object):
-    """
-    Class for Right Hands/Controllers in the ARENA.
-    """
-    object_type = "handRight"
-
-    def __init__(self,**kwargs):
-        self.camera = None
-        super().__init__(object_type=HandRight.object_type, **kwargs)
diff --git a/arena/objects/icosahedron.py b/arena/objects/icosahedron.py
index 82d5bd85..2228132d 100644
--- a/arena/objects/icosahedron.py
+++ b/arena/objects/icosahedron.py
@@ -2,7 +2,10 @@
 
 class Icosahedron(Object):
     """
-    Class for Icosahedron in the ARENA.
+    Icosahedron object class to manage its properties in the ARENA: Icosahedron Geometry.
+    
+    :param int detail: detail (optional)
+    :param float radius: radius Defaults to '1' (optional)
     """
     object_type = "icosahedron"
 
diff --git a/arena/objects/image.py b/arena/objects/image.py
index ddd54abb..23326c6e 100644
--- a/arena/objects/image.py
+++ b/arena/objects/image.py
@@ -2,7 +2,13 @@
 
 class Image(Object):
     """
-    Class for Image in the ARENA.
+    Image object class to manage its properties in the ARENA: Display an image on a plane.  See guidance to store paths under (https://docs.arenaxr.org/content/interface/filestore.html) ARENA File Store, CDN, or DropBox.
+    
+    :param float height: height Defaults to '1' (optional)
+    :param int segmentsHeight: segments height Defaults to '1' (optional)
+    :param int segmentsWidth: segments width Defaults to '1' (optional)
+    :param str url: Use File Store paths under 'store/users/username', see CDN and other storage options in the description above. (optional)
+    :param float width: width Defaults to '1' (optional)
     """
     object_type = "image"
 
diff --git a/arena/objects/light.py b/arena/objects/light.py
index c8e73955..6af512b7 100644
--- a/arena/objects/light.py
+++ b/arena/objects/light.py
@@ -2,7 +2,32 @@
 
 class Light(Object):
     """
-    Class for Light in the ARENA.
+    Light object class to manage its properties in the ARENA: A light.  More properties at (https://aframe.io/docs/1.5.0/components/light.html) A-Frame Light.
+    
+    :param float angle: Maximum extent of spot light from its direction (in degrees). Requires type:spot. Defaults to '60' (optional)
+    :param bool castShadow: Whether this light casts shadows on the scene. (optional)
+    :param str color: Light color. For 'hemisphere', light color from above. Defaults to '#ffffff' (optional)
+    :param float decay: Amount the light dims along the distance of the light. Requires type:point or type:spot. Defaults to '1' (optional)
+    :param float distance: Distance where intensity becomes 0. If distance is 0, then the point light does not decay with distance. Requires type:point or type:spot. (optional)
+    :param str envMap: Cube Map to load. (optional)
+    :param str groundColor: Light color from below. Requires type:hemisphere. Defaults to '#ffffff' (optional)
+    :param float intensity: Amount of light provided. Defaults to '1' (optional)
+    :param dict light:  (optional)
+    :param float penumbra: Percent of the spotlight cone that is attenuated due to penumbra. Requires type:spot. (optional)
+    :param float shadowBias: Offset depth when deciding whether a surface is in shadow. Tiny adjustments here (in the order of +/-0.0001) may reduce artifacts in shadows. (optional)
+    :param float shadowCameraBottom: Bottom plane of shadow camera frustum. Requires type:directional. Defaults to '-5' (optional)
+    :param float shadowCameraFar: Far plane of shadow camera frustum. Defaults to '500' (optional)
+    :param float shadowCameraFov: Shadow camera's FOV. Requires type:point or spot. Defaults to '50' (optional)
+    :param float shadowCameraLeft: Left plane of shadow camera frustum. Requires type:directional. Defaults to '-5' (optional)
+    :param float shadowCameraNear: Near plane of shadow camera frustum. Defaults to '0.5' (optional)
+    :param float shadowCameraRight: Right plane of shadow camera frustum. Requires type:directional. Defaults to '5' (optional)
+    :param float shadowCameraTop: Top plane of shadow camera frustum. Requires type:directional. Defaults to '5' (optional)
+    :param bool shadowCameraVisible: Displays a visual aid showing the shadow camera's position and frustum. This is the light's view of the scene, used to project shadows. (optional)
+    :param float shadowMapHeight: Shadow map's vertical resolution. Larger shadow maps display more crisp shadows, at the cost of performance. Defaults to '512' (optional)
+    :param float shadowMapWidth: Shadow map's horizontal resolution. Defaults to '512' (optional)
+    :param float shadowRadius: shadowRadius (castShadow=true) Defaults to '1' (optional)
+    :param str target: Id of element the spot should point to. Set to null to transform spotlight by orientation, pointing to it's -Z axis. Requires type:spot. (optional)
+    :param str type: The type of light, or what shape the light should take. Allows [ambient, directional, hemisphere, point, spot] Defaults to 'directional' (optional)
     """
     object_type = "light"
 
diff --git a/arena/objects/line.py b/arena/objects/line.py
index 4751519b..788bda2d 100644
--- a/arena/objects/line.py
+++ b/arena/objects/line.py
@@ -3,7 +3,13 @@
 
 class Line(Object):
     """
-    Class for Line in the ARENA.
+    Line object class to manage its properties in the ARENA: Draw a line.
+    
+    :param str color: Line color. Defaults to '#74BEC1' (optional)
+    :param dict end: End coordinate. Defaults to '{'x': -0.5, 'y': -0.5, 'z': 0}' (optional)
+    :param float opacity: Line opacity. Defaults to '1' (optional)
+    :param dict start: Start point coordinate. Defaults to '{'x': 0, 'y': 0.5, 'z': 0}' (optional)
+    :param bool visible: Whether the material is visible. Defaults to 'True' (optional)
     """
     object_type = "line"
 
diff --git a/arena/objects/ocean.py b/arena/objects/ocean.py
new file mode 100644
index 00000000..933f855b
--- /dev/null
+++ b/arena/objects/ocean.py
@@ -0,0 +1,22 @@
+from .arena_object import Object
+
+
+class Ocean(Object):
+    """
+    Ocean object class to manage its properties in the ARENA: Flat-shaded ocean primitive.
+
+    :param float amplitude: Wave amplitude. Defaults to '0.1' (optional)
+    :param float amplitudeVariance: Wave amplitude variance. Defaults to '0.3' (optional)
+    :param str color: Wave color. Defaults to '#7AD2F7' (optional)
+    :param float density: Density of waves. Defaults to '10' (optional)
+    :param float depth: Depth of the ocean area. Defaults to '10' (optional)
+    :param float opacity: Wave opacity. Defaults to '0.8' (optional)
+    :param float speed: Wave speed. Defaults to '1' (optional)
+    :param float speedVariance: Wave speed variance. Defaults to '2' (optional)
+    :param float width: Width of the ocean area. Defaults to '10' (optional)
+    """
+    object_type = "ocean"
+
+    def __init__(self, width=10, depth=10, **kwargs):
+        # NOTE: The 'ocean' component for a-frame requires at least depth or width before it will load.
+        super().__init__(object_type=Ocean.object_type, width=width, depth=depth, **kwargs)
diff --git a/arena/objects/octahedron.py b/arena/objects/octahedron.py
index 12a9c53b..9fe09b49 100644
--- a/arena/objects/octahedron.py
+++ b/arena/objects/octahedron.py
@@ -2,7 +2,10 @@
 
 class Octahedron(Object):
     """
-    Class for Octahedron in the ARENA.
+    Octahedron object class to manage its properties in the ARENA: Octahedron Geometry.
+    
+    :param int detail: detail (optional)
+    :param float radius: radius Defaults to '1' (optional)
     """
     object_type = "octahedron"
 
diff --git a/arena/objects/pcd_model.py b/arena/objects/pcd_model.py
new file mode 100644
index 00000000..5fae2a9b
--- /dev/null
+++ b/arena/objects/pcd_model.py
@@ -0,0 +1,14 @@
+from .arena_object import Object
+
+class PcdModel(Object):
+    """
+    PcdModel object class to manage its properties in the ARENA: Load a PCD model.  Format: (https://pointclouds.org/documentation/tutorials/index.html) Point Clouds. See guidance to store paths under (https://docs.arenaxr.org/content/interface/filestore.html) ARENA File Store, CDN, or DropBox.
+    
+    :param str pointColor: Color of the points. Defaults to '#7f7f7f' (optional)
+    :param float pointSize: Size of the points. Defaults to '0.01' (optional)
+    :param str url: Use File Store paths under 'store/users/username', see CDN and other storage options in the description above. (optional)
+    """
+    object_type = "pcd-model"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=PcdModel.object_type, **kwargs)
diff --git a/arena/objects/plane.py b/arena/objects/plane.py
index 19841626..8b2862a2 100644
--- a/arena/objects/plane.py
+++ b/arena/objects/plane.py
@@ -2,7 +2,12 @@
 
 class Plane(Object):
     """
-    Class for Plane in the ARENA.
+    Plane object class to manage its properties in the ARENA: Plane Geometry.
+    
+    :param float height: height Defaults to '1' (optional)
+    :param int segmentsHeight: segments height Defaults to '1' (optional)
+    :param int segmentsWidth: segments width Defaults to '1' (optional)
+    :param float width: width Defaults to '1' (optional)
     """
     object_type = "plane"
 
diff --git a/arena/objects/program.py b/arena/objects/program.py
new file mode 100644
index 00000000..cae4590a
--- /dev/null
+++ b/arena/objects/program.py
@@ -0,0 +1,80 @@
+from ..base_object import *
+from .arena_object import Object
+from ..attributes import Data
+import uuid
+import os
+
+from ..env import (
+    PROGRAM_OBJECT_ID,
+)
+
+class Program(BaseObject):
+    """
+    Program object in an ARENA Scene.
+
+    :param str name: Name of the program 
+    :param str affinity: Indicates the module affinity (client=client's runtime; none or empty=any suitable/available runtime)
+    :param str instantiate: Single instance of the program (=single), or let every client create a program instance (=client). Per client instance will create new uuid for each program.
+    :param str filename: Filename of the entry binary
+    :param str filetype: Type of the program (WA=WASM or PY=Python)
+    :param str parent: Request to deploy to this runtime (can be a runtime name or UUID); usually left blank.
+    :param str[] args: Command-line arguments (passed in argv); e.g. [ "arg=value" ]. 
+    :param str[] env: Environment variables; e.g. [ "SCENE=ascene" ].
+    
+    """
+
+    type = "program"
+    object_type = "program"
+ 
+    def __init__(self, object_id=str(uuid.uuid4()), persist=False, objects_list_add=True, **kwargs):
+                            
+        # remove timestamp, if exists
+        if "timestamp" in kwargs: del kwargs["timestamp"]
+
+        # remove "updatedAt", if exists
+        if "updatedAt" in kwargs: del kwargs["updatedAt"]
+
+        # remove "action", if exists
+        if "action" in kwargs: del kwargs["action"]
+                    
+        # update program object or print warning if existing object
+        if Object.exists(object_id):
+            if not Object.get(object_id).persist:
+                print("[WARNING]", f"An object with object_id of {object_id} was already created. The previous object will be overwritten.")
+            Object.remove(Object.get(object_id))
+
+        # setup attributes in the "data" field
+        data = kwargs.get("data", kwargs)
+        data = Data(**data)
+        super().__init__(
+                object_id=object_id,
+                type=Program.type,
+                persist=persist,
+                data=data
+            )
+
+        # add current object to all_objects dict
+        if objects_list_add: Object.add(self)
+
+    def update_attributes(self, evt_handler=None, update_handler=None, **kwargs):
+        if "data" not in self:
+            return
+
+        # update "persist"
+        self.persist = kwargs.get("persist", self.persist)
+
+        data = self.data
+        Data.update_data(data, kwargs)
+
+    def json(self, **kwargs):
+        json_data = {}
+        json_payload = { k: v for k, v in vars(self).items() }
+        json_payload.update(kwargs)
+        data = vars(json_payload["data"])
+
+        for k,v in data.items():
+            if v is None:
+                json_data[k] = v
+
+        json_payload["data"] = data
+        return self.json_encode(json_payload)
diff --git a/arena/objects/ring.py b/arena/objects/ring.py
index 28c364da..ecbe42c9 100644
--- a/arena/objects/ring.py
+++ b/arena/objects/ring.py
@@ -2,7 +2,14 @@
 
 class Ring(Object):
     """
-    Class for Ring in the ARENA.
+    Ring object class to manage its properties in the ARENA: Ring Geometry.
+    
+    :param float radiusInner: radius inner Defaults to '0.8' (optional)
+    :param float radiusOuter: radius outer Defaults to '1.2' (optional)
+    :param int segmentsPhi: segments phi Defaults to '10' (optional)
+    :param int segmentsTheta: segments theta Defaults to '32' (optional)
+    :param float thetaLength: theta length Defaults to '360' (optional)
+    :param float thetaStart: theta start (optional)
     """
     object_type = "ring"
 
diff --git a/arena/objects/roundedbox.py b/arena/objects/roundedbox.py
new file mode 100644
index 00000000..24cb868a
--- /dev/null
+++ b/arena/objects/roundedbox.py
@@ -0,0 +1,16 @@
+from .arena_object import Object
+
+class Roundedbox(Object):
+    """
+    Roundedbox object class to manage its properties in the ARENA: Rounded Box Geometry.
+    
+    :param float depth: depth Defaults to '1' (optional)
+    :param float height: height Defaults to '1' (optional)
+    :param float radius: radius of edge Defaults to '0.15' (optional)
+    :param int radiusSegments: segments radius Defaults to '10' (optional)
+    :param float width: width Defaults to '1' (optional)
+    """
+    object_type = "roundedbox"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=Roundedbox.object_type, **kwargs)
diff --git a/arena/objects/sphere.py b/arena/objects/sphere.py
index 57fa3f47..19fae32e 100644
--- a/arena/objects/sphere.py
+++ b/arena/objects/sphere.py
@@ -2,7 +2,15 @@
 
 class Sphere(Object):
     """
-    Class for Sphere in the ARENA.
+    Sphere object class to manage its properties in the ARENA: Sphere Geometry.
+    
+    :param float phiLength: phi length Defaults to '360' (optional)
+    :param float phiStart: phi start (optional)
+    :param float radius: radius Defaults to '1' (optional)
+    :param int segmentsHeight: segments height Defaults to '18' (optional)
+    :param int segmentsWidth: segments width Defaults to '36' (optional)
+    :param float thetaLength: theta length Defaults to '180' (optional)
+    :param float thetaStart: theta start (optional)
     """
     object_type = "sphere"
 
diff --git a/arena/objects/tetrahedron.py b/arena/objects/tetrahedron.py
index b3ba7ae9..893a2b23 100644
--- a/arena/objects/tetrahedron.py
+++ b/arena/objects/tetrahedron.py
@@ -2,7 +2,10 @@
 
 class Tetrahedron(Object):
     """
-    Class for Tetrahedron in the ARENA.
+    Tetrahedron object class to manage its properties in the ARENA: Tetrahedron Geometry.
+    
+    :param int detail: detail (optional)
+    :param float radius: radius Defaults to '1' (optional)
     """
     object_type = "tetrahedron"
 
diff --git a/arena/objects/text.py b/arena/objects/text.py
index 9544048a..c76aa36b 100644
--- a/arena/objects/text.py
+++ b/arena/objects/text.py
@@ -2,9 +2,34 @@
 
 class Text(Object):
     """
-    Class for Text in the ARENA.
+    Text object class to manage its properties in the ARENA: Display text.  More properties at (https://aframe.io/docs/1.5.0/components/text.html) A-Frame Text.
+
+    :param str align: Multi-line text alignment. Allows [left, center, right] Defaults to 'left' (optional)
+    :param float alphaTest: Discard text pixels if alpha is less than this value. Defaults to '0.5' (optional)
+    :param str anchor: Horizontal positioning. Allows [left, right, center, align] Defaults to 'center' (optional)
+    :param str baseline: Vertical positioning. Allows [top, center, bottom] Defaults to 'center' (optional)
+    :param str color: Text color. Defaults to '#000000' (optional)
+    :param str font: Font to render text, either the name of one of A-Frame's stock fonts or a URL to a font file. Allows [aileronsemibold, dejavu, exo2bold, exo2semibold, kelsonsans, monoid, mozillavr, roboto, sourcecodepro] Defaults to 'roboto' (optional)
+    :param str fontImage: Font image texture path to render text. Defaults to the font's name with extension replaced to .png. Don't need to specify if using a stock font. (derived from font name). (optional)
+    :param float height: Height of text block. (derived from text size). (optional)
+    :param float letterSpacing: Letter spacing in pixels. (optional)
+    :param float lineHeight: Line height in pixels. (derived from font file). (optional)
+    :param float opacity: Opacity, on a scale from 0 to 1, where 0 means fully transparent and 1 means fully opaque. Defaults to '1' (optional)
+    :param str shader: Shader used to render text. Allows [portal, flat, standard, sdf, msdf, ios10hls, skyshader, gradientshader] Defaults to 'sdf' (optional)
+    :param str side: Side to render. Allows [front, back, double] Defaults to 'double' (optional)
+    :param float tabSize: Tab size in spaces. Defaults to '4' (optional)
+    :param bool transparent: Whether text is transparent. Defaults to 'True' (optional)
+    :param str value: The actual content of the text. Line breaks and tabs are supported with \n and \t. (optional)
+    :param str whiteSpace: How whitespace should be handled. Allows [normal, pre, nowrap] Defaults to 'normal' (optional)
+    :param float width: Width in meters. (derived from geometry if exists). Defaults to '5' (optional)
+    :param float wrapCount: Number of characters before wrapping text (more or less). Defaults to '40' (optional)
+    :param float wrapPixels: Number of pixels before wrapping text. (derived from wrapCount). (optional)
+    :param float xOffset: X-offset to apply to add padding. (optional)
+    :param float zOffset: Z-offset to apply to avoid Z-fighting if using with a geometry as a background. Defaults to '0.001' (optional)
     """
     object_type = "text"
 
-    def __init__(self, text="placeholder text", **kwargs):
-        super().__init__(object_type=Text.object_type, text=text, **kwargs)
+    def __init__(self, **kwargs):
+        # NOTE: Don't require parameter 'text' or 'value' we won't know which one users
+        #       used downstream into persist
+        super().__init__(object_type=Text.object_type, **kwargs)
diff --git a/arena/objects/thickline.py b/arena/objects/thickline.py
index 9658c7a3..3ffde698 100644
--- a/arena/objects/thickline.py
+++ b/arena/objects/thickline.py
@@ -2,9 +2,14 @@
 from .arena_object import Object
 from ..attributes import Attribute, Position
 
-class ThickLine(Object):
+class Thickline(Object):
     """
-    Class for ThickLine in the ARENA.
+    Thickline object class to manage its properties in the ARENA: Draw a line that can have a custom width.
+    
+    :param str color: Line color. Defaults to '#000000' (optional)
+    :param float lineWidth: Width of line in px. Defaults to '1' (optional)
+    :param str lineWidthStyler: Allows defining the line width as a function of relative position p along the path of the line. By default it is set to a constant 1. You may also choose one of the preset functions. Allows [default, grow, shrink, center-sharp, center-smooth, sine-wave] Defaults to 'default' (optional)
+    :param str path: Comma-separated list of x y z coordinates of the line vertices. Defaults to '-2 -1 0, 0 20 0, 10 -1 10' (optional)
     """
     object_type = "thickline"
 
@@ -25,3 +30,9 @@ def __init__(self, path=None, lineWidth=1, **kwargs):
             path_str += p + ","
         path_str = path_str.rstrip(",")
         super().__init__(object_type=ThickLine.object_type, path=path_str, lineWidth=lineWidth, **kwargs)
+
+
+class ThickLine(Thickline):
+    """
+    Alternate name for Thickline.
+    """
diff --git a/arena/objects/threejs_scene.py b/arena/objects/threejs_scene.py
new file mode 100644
index 00000000..70d68358
--- /dev/null
+++ b/arena/objects/threejs_scene.py
@@ -0,0 +1,12 @@
+from .arena_object import Object
+
+class ThreejsScene(Object):
+    """
+    ThreejsScene object class to manage its properties in the ARENA: Load a Three.js Scene.  Could be THREE.js version-specific; you can see the THREE.js version in the JS console once you open ARENA; using glTF is preferred. Format: (https://threejs.org/docs/#api/en/scenes/Scene) THREE.js Scene. See guidance to store paths under (https://docs.arenaxr.org/content/interface/filestore.html) ARENA File Store, CDN, or DropBox.
+    
+    :param str url: Use File Store paths under 'store/users/username', see CDN and other storage options in the description above. (optional)
+    """
+    object_type = "threejs-scene"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=ThreejsScene.object_type, **kwargs)
diff --git a/arena/objects/torus.py b/arena/objects/torus.py
index 9459a5e4..f75aff95 100644
--- a/arena/objects/torus.py
+++ b/arena/objects/torus.py
@@ -2,7 +2,13 @@
 
 class Torus(Object):
     """
-    Class for Torus in the ARENA.
+    Torus object class to manage its properties in the ARENA: Torus Geometry.
+    
+    :param float arc: Arc Defaults to '360' (optional)
+    :param float radius: radius Defaults to '1' (optional)
+    :param float radiusTubular: radius tubular Defaults to '0.2' (optional)
+    :param int segmentsRadial: segments radial Defaults to '36' (optional)
+    :param int segmentsTubular: segments tubular Defaults to '32' (optional)
     """
     object_type = "torus"
 
diff --git a/arena/objects/torus_knot.py b/arena/objects/torus_knot.py
index 2c3bb6d9..42d22359 100644
--- a/arena/objects/torus_knot.py
+++ b/arena/objects/torus_knot.py
@@ -2,7 +2,14 @@
 
 class TorusKnot(Object):
     """
-    Class for TorusKnot in the ARENA.
+    TorusKnot object class to manage its properties in the ARENA: Torus Knot Geometry.
+    
+    :param float p: P Defaults to '2' (optional)
+    :param float q: Q Defaults to '3' (optional)
+    :param float radius: radius Defaults to '1' (optional)
+    :param float radiusTubular: radius tubular Defaults to '0.2' (optional)
+    :param int segmentsRadial: segments radial Defaults to '8' (optional)
+    :param int segmentsTubular: segments tubular Defaults to '100' (optional)
     """
     object_type = "torusKnot"
 
diff --git a/arena/objects/triangle.py b/arena/objects/triangle.py
index 68be8037..ebad250c 100644
--- a/arena/objects/triangle.py
+++ b/arena/objects/triangle.py
@@ -2,7 +2,11 @@
 
 class Triangle(Object):
     """
-    Class for Triangle in the ARENA.
+    Triangle object class to manage its properties in the ARENA: Triangle Geometry.
+    
+    :param dict vertexA: vertex A Defaults to '{'x': 0, 'y': 0.5, 'z': 0}' (optional)
+    :param dict vertexB: vertex B Defaults to '{'x': -0.5, 'y': -0.5, 'z': 0}' (optional)
+    :param dict vertexC: vertex C Defaults to '{'x': 0.5, 'y': -0.5, 'z': 0}' (optional)
     """
     object_type = "triangle"
 
diff --git a/arena/objects/ui.py b/arena/objects/ui.py
deleted file mode 100644
index 0125caeb..00000000
--- a/arena/objects/ui.py
+++ /dev/null
@@ -1,90 +0,0 @@
-from .arena_object import Object
-
-
-class Button:
-    """
-    Buttons of a ButtonPanel in the ARENA UI.
-
-    :param str name: Button Name
-    :param str img: Image URl for image buttons (optional)
-    :param float size: Size of image button (optional)
-    :param float height: Height of image button, overrides size (optional)
-    :param float: Width of image button, overrides size (optional)
-    :param float borderRadius: Border radius of image button (optional)
-    """
-
-    def __init__(self, name="Button", **kwargs):
-        self.name = name
-        self.__dict__.update(kwargs)
-
-
-class ButtonPanel(Object):
-    """
-    Button Panel in the ARENA UI.
-
-    :param list[Button] buttons: List of Button objects
-    :param str title: Title of Button Panel (optional)
-    :param bool vertical: Whether to display buttons vertically (optional)
-    :param str font: Font of button panel ['Roboto', 'Roboto-Mono'] (optional)
-    """
-
-    object_type = "arenaui-button-panel"
-
-    def __init__(self, **kwargs):
-        super().__init__(object_type=ButtonPanel.object_type, **kwargs)
-
-    def json_preprocess(self, **kwargs):
-        # kwargs are for additional param to add to json, like "action":"create"
-        skipped_keys = [
-            "evt_handler",
-            "update_handler",
-            "animations",
-            "delayed_prop_tasks",
-        ]
-        json_payload = {k: v for k, v in vars(self).items() if k not in skipped_keys}
-        json_payload["data"]["buttons"] = [
-            vars(button) if hasattr(button, "__dict__") else button
-            for button in json_payload["data"]["buttons"]
-        ]
-        json_payload.update(kwargs)
-        return json_payload
-
-
-class Prompt(Object):
-    """
-    Popup Prompt in the ARENA UI.
-
-    :param str title: Title of Prompt (optional)
-    :param str description: Additional desc text of prompt (optional)
-    :param list[str] buttons: List of button name strings (optional)
-    :param str width: Width of prompt (optional)
-    :param str font: Font of button panel ['Roboto', 'Roboto-Mono'] (optional)
-    """
-
-    object_type = "arenaui-prompt"
-
-    def __init__(self, **kwargs):
-        super().__init__(object_type=Prompt.object_type, **kwargs)
-
-
-class Card(Object):
-    """
-    Text/Image Card in the ARENA UI.
-
-    :param str title: Title of Card (optional)
-    :param str body: Body text of Card (optional)
-    :param str bodyAlign: Text alignment of body text ['center', 'left', 'right', 'justify'] (optional)
-    :param str img: Image URL of Card (optional)
-    :param str imgCaption: Image caption (optional)
-    :param str imgDirection: Left or Right image placement vs body text [ 'left', 'right'] (optional)
-    :param str imgSize: Container size fitting of image ['cover', 'contain'] (optional)
-    :param float fontSize: Font size of card, scales both title and body (optional)
-    :param float widthScale: Width of card as a factor of the default (optional)
-    :param bool closeButton: Whether to display a close button (optional)
-    :param str font: Font of card ['Roboto', 'Roboto-Mono'] (optional)
-    """
-
-    object_type = "arenaui-card"
-
-    def __init__(self, **kwargs):
-        super().__init__(object_type=Card.object_type, **kwargs)
diff --git a/arena/objects/videosphere.py b/arena/objects/videosphere.py
new file mode 100644
index 00000000..ebcd3f49
--- /dev/null
+++ b/arena/objects/videosphere.py
@@ -0,0 +1,18 @@
+from .arena_object import Object
+
+class Videosphere(Object):
+    """
+    Videosphere object class to manage its properties in the ARENA: Videosphere Geometry.
+    
+    :param bool autoplay: Autoplay Defaults to 'True' (optional)
+    :param str crossOrigin: Cross Origin Defaults to 'anonymous' (optional)
+    :param bool loop: Loop Defaults to 'True' (optional)
+    :param float radius: Radius Defaults to '5000' (optional)
+    :param int segmentsHeight: Segments Height Defaults to '64' (optional)
+    :param int segmentsWidth: Segments Width Defaults to '64' (optional)
+    :param str src: URI, relative or full path of an image/video file. e.g. 'store/users/wiselab/images/360falls.mp4'. (optional)
+    """
+    object_type = "videosphere"
+
+    def __init__(self, **kwargs):
+        super().__init__(object_type=Videosphere.object_type, **kwargs)
diff --git a/arena/scene.py b/arena/scene.py
index f7770768..2bc7c274 100644
--- a/arena/scene.py
+++ b/arena/scene.py
@@ -5,12 +5,19 @@
 import sys
 from datetime import datetime
 from inspect import signature
+import threading
 
 from .arena_mqtt import ArenaMQTT
 from .attributes import *
 from .events import *
 from .objects import *
-from .utils import *
+from .utils import Utils, ArenaTelemetry, ArenaCmdInterpreter, ProgramRunInfo, QueueStats
+
+from .env import (
+    SCENE,
+    PROGRAM_OBJECT_ID,
+    _get_env
+)
 
 class Scene(ArenaMQTT):
     """
@@ -39,6 +46,18 @@ def __init__(
                 cli_args = False,
                 **kwargs
             ):
+                
+        # init telemetry
+        self.telemetry = ArenaTelemetry()        
+        
+        # setup event to let others wait on connection
+        self.connected_evt = threading.Event()
+                
+        # start the command interpreter (if enabled by env variable)
+        self.cmd_interpreter = ArenaCmdInterpreter(self, show_attrs=('config_data', 'scene', 'users', 'all_objects', 'run_info'),
+                                    get_callables=('persisted_objs', 'persisted_scene_option', 'writable_scenes', 'user_list'),
+                                    start_cmd_event=self.connected_evt)
+                
         if cli_args:
             self.args = self.parse_cli()
             if self.args["host"]:
@@ -50,17 +69,18 @@ def __init__(
             if self.args["debug"]:
                 debug = self.args["debug"]
 
-        if os.environ.get("SCENE"):
-            self.scene = os.environ["SCENE"]
+        if os.environ.get(SCENE):
+            self.scene = _get_env(SCENE)
             print(f"Using Scene from 'SCENE' env variable: {self.scene}")
         elif "scene" in kwargs and kwargs["scene"]:
-            if re.search("/", kwargs["scene"]):
-                sys.exit("Scene argument (scene) cannot include '/', aborting...")
             self.scene = kwargs["scene"]
             print(f"Using Scene from 'scene' input parameter: {self.scene}")
         else:
-            sys.exit("Scene argument (scene) is unspecified or None, aborting...")
+            self.exit("Scene argument (scene) is unspecified or None, aborting...")
 
+        if re.search("/", self.scene):
+            self.exit("Scene cannot include '/', aborting...")
+            
         super().__init__(
             host,
             realm,
@@ -72,38 +92,79 @@ def __init__(
             **kwargs
         )
 
-        self.persist_host = self.config_data["ARENADefaults"]["persistHost"]
-        self.persist_path = self.config_data["ARENADefaults"]["persistPath"]
-
-        self.persist_url = f"https://{self.persist_host}{self.persist_path}{self.namespaced_target}"
-
-        # set up callbacks
-        self.new_obj_callback = new_obj_callback
-        self.delete_obj_callback = delete_obj_callback
-        self.user_join_callback = user_join_callback
-        self.user_left_callback = user_left_callback
-
-        self.unspecified_object_ids = set() # objects that exist in the scene,
-                                            # but this scene instance does not
-                                            # have a reference to
-        self.users = {} # dict of all users
-
-        # Always use the the hostname specified by the user, or defaults.
-        print(f"Loading: https://{self.web_host}/{self.namespace}/{self.scene}, realm={self.realm}")
-
+        with self.telemetry.start_span(f"init {self.namespace}/{self.scene}") as span:
+            # 'init' span will track the remainder of the initialization
+
+            # create a program object to describe this program
+            # PROGRAM_OBJECT_ID allows to match the object id of persisted program object
+            # when a program object with PROGRAM_OBJECT_ID is loaded from persist, it will replace this one
+            self.program = Program(object_id=_get_env(PROGRAM_OBJECT_ID), 
+                                   name=f"{self.namespace}/{self.scene}", 
+                                   filename=sys.argv[0], 
+                                   filetype="PY")
+                        
+            self.persist_host = self.config_data["ARENADefaults"]["persistHost"]
+            self.persist_path = self.config_data["ARENADefaults"]["persistPath"]
+
+            self.persist_url = f"https://{self.persist_host}{self.persist_path}{self.namespaced_target}"
+
+            # set up callbacks
+            self.new_obj_callback = new_obj_callback
+            self.delete_obj_callback = delete_obj_callback
+            self.user_join_callback = user_join_callback
+            self.user_left_callback = user_left_callback
+
+            self.unspecified_object_ids = set() # objects that exist in the scene,
+                                                # but this scene instance does not
+                                                # have a reference to
+            self.users = {} # dict of all users
+
+            # setup program run info to collect stats
+            self.run_info = ProgramRunInfo(self.event_loop,
+                                           queue_len_callable=lambda: self.get_rcv_pub_queue_len(), 
+                                           update_callback=self.run_info_update, 
+                                           web_host=self.web_host,
+                                           namespace=self.namespace,
+                                           scene=self.scene,
+                                           realm=self.realm
+                                           )
+
+            # Always use the the hostname specified by the user, or defaults.
+            print(f"Loading: https://{self.web_host}/{self.namespace}/{self.scene}, realm={self.realm}")
+                        
+            span.add_event(f"Loading: https://{self.web_host}/{self.namespace}/{self.scene}, realm={self.realm}")
+
+    def exit(self, arg=0):
+        """Custom exit to push errors to telemetry"""
+        error_msg=None
+        if arg != 0:
+            error_msg = f"Exiting with sys.exit('{arg}')"
+        self.telemetry.exit(error_msg)
+        os._exit(arg)
+            
     def on_connect(self, client, userdata, flags, rc):
         super().on_connect(client, userdata, flags, rc)
         if rc == 0:
+            # set event
+            self.connected_evt.set()
+
             # create arena-py Objects from persist server
-            # no need to return anything here
+            # no need to return anything here            
             self.get_persisted_objs()
 
+    def on_message(self, client, userdata, msg):
+        super().on_message(client, userdata, msg)
+        self.run_info.msg_rcv()
+
+    def on_publish(self, client, userdata, mid):
+        self.run_info.msg_publish()
+        
     async def process_message(self):
         while True:
             try:
                 msg = await self.msg_queue.get()
             except RuntimeError as e:
-                print(f"Ignoring error: {e}")
+                self.telemetry.add_event(f"Ignoring error: {e}")                
                 return
 
             # extract payload
@@ -111,117 +172,127 @@ async def process_message(self):
                 payload_str = msg.payload.decode("utf-8", "ignore")
                 payload = json.loads(payload_str)
             except Exception as e:
-                print("Malformed payload, ignoring:")
-                print(e)
+                self.telemetry.add_event(f"Malformed payload: {payload_str}. {e}.")
                 return
 
-            try:
-                # update object attributes, if possible
-                if "object_id" in payload:
-                    # parese payload
-                    object_id = payload.get("object_id", None)
-                    action = payload.get("action", None)
+            object_id = payload.get("object_id", None)
+            action = payload.get("action", None)
 
-                    data = payload.get("data", {})
-                    object_type = data.get("object_type", None)
+            with self.telemetry.start_process_msg_span(object_id, action) as span:    
+                try:
+                    # update object attributes, if possible
+                    if object_id:
+                        data = payload.get("data", {})
+                        object_type = data.get("object_type", None)
 
-                    event = None
+                        event = None
+
+                        # create/get object from object_id
+                        if object_id in self.all_objects:
+                            obj = self.all_objects[object_id]
+                        else:
+                            ObjClass = OBJECT_TYPE_MAP.get(object_type, Object)
+                            obj = ObjClass(**payload)
+
+                        # react to action accordingly
+                        if action:
+                            if action == "clientEvent":
+                                event = Event(**payload)
+                                if obj.evt_handler:
+                                    self.callback_wrapper(obj.evt_handler, event, payload)
+                                    continue
+                                span.add_event("Client event: {event}")
+
+
+                            elif action == "delete":
+                                if Camera.object_type in object_id: # object is a camera
+                                    if object_id in self.users:
+                                        if self.user_left_callback:
+                                            self.callback_wrapper(
+                                                    self.user_left_callback,
+                                                    self.users[object_id],
+                                                    payload
+                                                )
+                                        del self.users[object_id]
+                                elif HandLeft.object_type in object_id or HandRight.object_type in object_id: # object is a hand/controller
+                                    if "dep" in obj.data:
+                                        user_id = obj.data.dep
+                                        if user_id in self.users:
+                                            user = self.users[user_id]
+                                            if obj in user.hands.values():
+                                                if user.hand_remove_callback:
+                                                    self.callback_wrapper(
+                                                            user.hand_remove_callback,
+                                                            obj,
+                                                            payload
+                                                        )
+                                                hand_key = HandLeft.object_type if HandLeft.object_type in object_id else HandRight.object_type
+                                                del user.hands[hand_key]
+                                elif self.delete_obj_callback:
+                                    self.callback_wrapper(self.delete_obj_callback, obj, payload)
+                                Object.remove(obj)
+                                span.add_event("Object delete.")
 
-                    # create/get object from object_id
-                    if object_id in self.all_objects:
-                        obj = self.all_objects[object_id]
-                    else:
-                        ObjClass = OBJECT_TYPE_MAP.get(object_type, Object)
-                        obj = ObjClass(**payload)
-
-                    # react to action accordingly
-                    if action:
-                        if action == "clientEvent":
-                            event = Event(**payload)
-                            if obj.evt_handler:
-                                self.callback_wrapper(obj.evt_handler, event, payload)
                                 continue
 
-                        elif action == "delete":
-                            if Camera.object_type in object_id: # object is a camera
-                                if object_id in self.users:
-                                    if self.user_left_callback:
+                            else: # create/update
+                                obj.update_attributes(**payload)
+                                span.add_event("Object attributes update.")
+                        
+                        else:
+                            self.telemetry.set_error("No message action!", span)
+
+                        # call new message callback for all messages
+                        if self.on_msg_callback:
+                            if not event:
+                                self.callback_wrapper(self.on_msg_callback, obj, payload)
+                            else:
+                                self.callback_wrapper(self.on_msg_callback, event, payload)
+
+                        if object_type:
+                            # run user_join_callback when user is found
+                            if object_type == Camera.object_type:
+                                if object_id not in self.users:
+                                    if object_id in self.all_objects:
+                                        self.users[object_id] = obj
+                                    else:
+                                        self.users[object_id] = Camera(**payload)
+
+                                    if self.user_join_callback:
                                         self.callback_wrapper(
-                                                self.user_left_callback,
+                                                self.user_join_callback,
                                                 self.users[object_id],
                                                 payload
                                             )
-                                    del self.users[object_id]
-                            elif HandLeft.object_type in object_id or HandRight.object_type in object_id: # object is a hand/controller
+
+                            elif object_type == HandLeft.object_type or object_type == HandRight.object_type:
                                 if "dep" in obj.data:
                                     user_id = obj.data.dep
                                     if user_id in self.users:
                                         user = self.users[user_id]
-                                        if obj in user.hands.values():
-                                            if user.hand_remove_callback:
-                                                self.callback_wrapper(
-                                                        user.hand_remove_callback,
-                                                        obj,
-                                                        payload
-                                                    )
-                                            hand_key = HandLeft.object_type if HandLeft.object_type in object_id else HandRight.object_type
-                                            del user.hands[hand_key]
-                            elif self.delete_obj_callback:
-                                self.callback_wrapper(self.delete_obj_callback, obj, payload)
-                            Object.remove(obj)
-                            continue
-
-                        else: # create/update
-                            obj.update_attributes(**payload)
-
-                    # call new message callback for all messages
-                    if self.on_msg_callback:
-                        if not event:
-                            self.callback_wrapper(self.on_msg_callback, obj, payload)
-                        else:
-                            self.callback_wrapper(self.on_msg_callback, event, payload)
-
-                    if object_type:
-                        # run user_join_callback when user is found
-                        if object_type == Camera.object_type:
-                            if object_id not in self.users:
-                                if object_id in self.all_objects:
-                                    self.users[object_id] = obj
-                                else:
-                                    self.users[object_id] = Camera(**payload)
-
-                                if self.user_join_callback:
-                                    self.callback_wrapper(
-                                            self.user_join_callback,
-                                            self.users[object_id],
-                                            payload
-                                        )
-
-                        elif object_type == HandLeft.object_type or object_type == HandRight.object_type:
-                            if "dep" in obj.data:
-                                user_id = obj.data.dep
-                                if user_id in self.users:
-                                    user = self.users[user_id]
-                                    if obj not in user.hands.values():
-                                        user.hands[object_type] = obj
-                                        obj.camera = user
-
-                                        if user.hand_found_callback:
-                                            self.callback_wrapper(
-                                                user.hand_found_callback,
-                                                obj,
-                                                payload
-                                            )
+                                        if obj not in user.hands.values():
+                                            user.hands[object_type] = obj
+                                            obj.camera = user
 
-                    # if its an object the library has not seen before, call new object callback
-                    elif object_id not in self.unspecified_object_ids and self.new_obj_callback:
-                        self.callback_wrapper(self.new_obj_callback, obj, payload)
-                        self.unspecified_object_ids.add(object_id)
+                                            if user.hand_found_callback:
+                                                self.callback_wrapper(
+                                                    user.hand_found_callback,
+                                                    obj,
+                                                    payload
+                                                )
+
+                        # if its an object the library has not seen before, call new object callback
+                        elif object_id not in self.unspecified_object_ids and self.new_obj_callback:
+                            self.callback_wrapper(self.new_obj_callback, obj, payload)
+                            self.unspecified_object_ids.add(object_id)
+                            span.add_event("New Object.")
+                        
+                        span.add_event("Handle Msg Done.")
+                    else:
+                        self.telemetry.set_error("No object id!", span)
 
-            except Exception as e:
-                print("Something went wrong, ignoring:")
-                print(e)
-                print(payload)
+                except Exception as e:                    
+                    self.telemetry.set_error(f"Something went wrong, ignoring: {payload}. {e}")
 
     def callback_wrapper(self, func, arg, msg):
         """Checks for number of arguments for callback"""
@@ -404,8 +475,7 @@ async def _delayed_task():
                 self.update_object(obj)
                 obj.delayed_prop_tasks.pop(anim["property"], None)
             except asyncio.CancelledError:
-                print("Animation end task cancelled for",
-                      obj.object_id + "." + anim["property"])
+                self.telemetry.add_event(f"Animation end task cancelled for {obj.object_id}.{anim['property']}")
 
         if anim.get("loop", 0) or anim.get("enabled", True) is False:
             return None
@@ -417,26 +487,31 @@ async def _delayed_task():
 
     def _publish(self, obj, action, custom_payload=False):
         """Publishes to mqtt broker with "action":action"""
-        if not self.can_publish:
-            print(f"ERROR: Publish failed! You do not have permission to publish to topic {self.root_topic} on {self.web_host}")
-
-        topic = f"{self.root_topic}/{self.mqttc_id}/{obj['object_id']}"
-        d = datetime.utcnow().isoformat()[:-3]+"Z"
-
-        if custom_payload:
-            payload = obj
-            payload["action"] = action
-            payload["timestamp"] = d
-            payload = json.dumps(payload)
-        else:
-            payload = obj.json(action=action, timestamp=d)
-
-        self.mqttc.publish(topic, payload, qos=0)
-        if self.debug: print("[publish]", topic, payload)
-        return payload
+        with self.telemetry.start_publish_span(obj.object_id, action, obj.type) as span:
+            if not self.can_publish:
+                self.telemetry.set_error(f"ERROR: Publish failed! You do not have permission to publish to topic {self.root_topic} on {self.web_host}", span)
+
+            topic = f"{self.root_topic}/{self.mqttc_id}/{obj['object_id']}"
+            d = datetime.utcnow().isoformat()[:-3]+"Z"
+
+            if custom_payload:
+                payload = obj
+                payload["action"] = action
+                payload["timestamp"] = d
+                payload = json.dumps(payload)
+            else:
+                payload = obj.json(action=action, timestamp=d)
+
+            self.mqttc.publish(topic, payload, qos=0)
+            if self.debug: 
+                self.telemetry.add_event(f"[publish] {topic} {payload}")
+            return payload
 
     def get_persisted_obj(self, object_id):
-        """Returns a dictionary for a persisted object."""
+        """Returns a dictionary for a persisted object.
+        
+           If object is known by arena-py, return local object, not persisted
+        """
         obj = None
         if object_id in self.all_objects:
             obj = self.all_objects[object_id]
@@ -444,40 +519,61 @@ def get_persisted_obj(self, object_id):
         else:
             # pass token to persist
             data = self.auth.urlopen(url=f"{self.persist_url}/{object_id}", creds=True)
-            output = json.loads(data)
-            if len(output) > 0:
-                output = output[0]
-
-                object_id = output["object_id"]
-                data = output["attributes"]
-                object_type = data.get("object_type", None)
-
-                ObjClass = OBJECT_TYPE_MAP.get(object_type, Object)
-                obj = ObjClass(object_id=object_id, data=data)
-                obj.persist = True
-
+            persist_obj = json.loads(data)
+            if len(obj) > 0:
+                obj = obj[0]
+
+                obj_id = persist_obj.get("object_id", object_id)
+                data = persist_obj.get("attributes", {})
+                object_type = data.get("object_type")
+
+                # special case for Program type
+                if persist_obj.get("type") == Program.object_type: object_type = Program.object_type
+
+                if object_type != None:
+                    obj_class = OBJECT_TYPE_MAP.get(object_type, Object)
+                    obj = obj_class(object_id=obj_id, data=data)
+                    obj.persist = True
+                    
         return obj
-
+    
     def get_persisted_objs(self):
-        """Returns a dictionary of persisted objects. [TODO] check object_type"""
+        """Returns a dictionary of persisted objects.
+        
+           If object is known by arena-py, return our local object, not persisted
+           Silently fails/skip objects without object_id and object_type (except programs)
+           Instanciates generic Object if object_type is given but unknown to arena-py
+        """
         objs = {}
         # pass token to persist
         data = self.auth.urlopen(url=self.persist_url, creds=True)
         output = json.loads(data)
         for obj in output:
-            if obj["type"] == Object.object_type or obj["type"] == Object.type:
-                object_id = obj["object_id"]
-                data = obj["attributes"]
-
+            # note: no exception on missing fields
+            object_id = obj.get("object_id")
+            data = obj.get("attributes", {})
+            object_type = data.get("object_type")
+            
+            # special case for Program type
+            if obj.get("type") == Program.object_type: 
+                object_type = Program.object_type
+            
+            if object_id != None:
                 if object_id in self.all_objects:
+                    # note: object from our list (not from persist)
                     persisted_obj = self.all_objects[object_id]
                     persisted_obj.persist = True
-                else:
-                    object_type = data.get("object_type", None)
-                    ObjClass = OBJECT_TYPE_MAP.get(object_type, Object)
-                    persisted_obj = ObjClass(object_id=object_id, data=data)
+                elif object_type != None:
+                    # note: instanciate even with empty attributes if object_type is unknown to arena-py
+                    obj_class = OBJECT_TYPE_MAP.get(object_type, Object)
+                    persisted_obj = obj_class(object_id=object_id, data=data)
                     persisted_obj.persist = True
-
+                    
+                    # replace program object, if matches our program id
+                    if object_type == Program.object_type:
+                        if os.environ.get(PROGRAM_OBJECT_ID) == object_id:
+                            self.program = persisted_obj
+            
                 objs[object_id] = persisted_obj
 
         return objs
@@ -500,6 +596,17 @@ def get_user_list(self):
         """Returns a list of users"""
         return self.users.values()
 
+    def get_rcv_pub_queue_len(self):
+        """Return QueueStats object with receive and publish queue length"""
+        return QueueStats(super().rcv_queue_len(),  super().pub_queue_len())
+    
+    def run_info_update(self, run_info):
+        """Callbak when program stats are updated; publish program object update"""
+        # Add run info to program data object and publish program object update
+        run_info.add_program_info(self.program.data)
+        self.program.persist = True
+        self._publish(self.program, "update")
+        
 class Arena(Scene):
     """
     Another name for Scene.
diff --git a/arena/utils/__init__.py b/arena/utils/__init__.py
index 7305c129..27e8e0d7 100644
--- a/arena/utils/__init__.py
+++ b/arena/utils/__init__.py
@@ -1,2 +1,4 @@
 from .utils import Utils
-from .cmd_interpreter import ArenaCmdInterpreter
\ No newline at end of file
+from .cmd_interpreter import ArenaCmdInterpreter
+from .arena_telemetry import ArenaTelemetry
+from .program_info import ProgramRunInfo, QueueStats
\ No newline at end of file
diff --git a/arena/utils/arena_telemetry.py b/arena/utils/arena_telemetry.py
new file mode 100644
index 00000000..5c32aba9
--- /dev/null
+++ b/arena/utils/arena_telemetry.py
@@ -0,0 +1,166 @@
+"""
+The ArenaTelemetry generates traces, metrics, and logs using OpenTelemetry (OTEL).
+It can export using OTEL's protocol (OTLP), send JSON OTEL spans to MQTT, or to the console. 
+
+The :envvar:`ARENA_TELEMETRY` environment variable enables the telemetry.
+The :envvar:`OTLP_ENDPOINT` environment variable defines the OTLP endpoint when OTLP is used.
+"""
+import atexit
+import sys
+import os
+import signal 
+import time
+
+from typing import Callable, Optional, List, Sequence
+
+from opentelemetry import trace
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.sdk.resources import SERVICE_NAME, Resource
+from opentelemetry.sdk.trace import TracerProvider, ReadableSpan, Span
+from opentelemetry.trace import NoOpTracerProvider, Status, StatusCode
+from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SpanExporter, SpanExportResult
+from opentelemetry.trace.span import INVALID_SPAN
+
+from ..env import (
+    ARENA_TELEMETRY,
+    OTLP_ENDPOINT,
+    _get_env
+)
+
+TRACE_TOPIC_DFT = "realm/ns/scene/t/traces"
+
+class MQTTSpanExporter(SpanExporter):
+    """Implementation of :class:`SpanExporter` that sends spans to MQTT"""
+
+    def __init__(
+        self,
+        topic: Optional[str] = TRACE_TOPIC_DFT,
+        service_name: Optional[str] = None,
+        formatter: Callable[
+            [ReadableSpan], str
+        ] = lambda span: span.to_json()
+        + os.linesep,
+    ):
+        # TODO: create mqtt client and assign its publish calls
+        self.publish = None
+        self.publish_flush = None
+        
+        self.topic = topic
+        self.formatter = formatter
+        self.service_name = service_name
+
+    def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
+        for span in spans:
+            #self.out.write(self.formatter(span))
+            self.publish(self.topic, self.formatter(span))
+        if self.publish_flush: self.publish_flush(True)
+        return SpanExportResult.SUCCESS
+
+    def force_flush(self, timeout_millis: int = 30000) -> bool:
+        return True
+    
+    def shutdown(self) -> None:
+        print("shutdown exporter!")
+        pass
+    
+class ArenaTelemetry():
+    """Implementation of ARENA telemetry. 
+       According to :envvar:`ARENA_TELEMETRY`, exports using OTLP, send JSON OTEL spans to MQTT, or to the console. 
+    """
+    
+    parent_span: Span = None
+
+    def __init__(self, name=sys.argv[0], id=None):
+        """ Return an `ArenaTelemetry` using given service name and id
+            Provides utility calls that wrap open telemetry functionality to start spans, log events, and other.
+        
+            Creates a parent span for all the spans related to the program.
+        
+            Args:
+                name: name of the service used with the telemetry backend
+                id: additional id used with the telemetry backend
+        """
+            
+        service_name = f"{name}"
+        if id: service_name = service_name + "({id})"
+        # Service name is required for most backends
+        resource = Resource(attributes={
+            SERVICE_NAME: service_name
+        })
+        
+        env_telemetry = _get_env(ARENA_TELEMETRY)
+        otlp_endpoint = _get_env(OTLP_ENDPOINT)
+        tel_exporters = {
+            'otlp': lambda: OTLPSpanExporter(otlp_endpoint, insecure=True),
+            'mqtt': lambda: MQTTSpanExporter(), 
+            'console': lambda: ConsoleSpanExporter()
+        }
+        
+        if env_telemetry: self.enabled = env_telemetry.lower() in tel_exporters.keys()
+        else: 
+            env_telemetry = 'none'
+            self.enabled = False
+        
+        if self.enabled:
+            provider = TracerProvider(resource=resource)
+            self.exporter = tel_exporters[env_telemetry]()
+            processor = BatchSpanProcessor(self.exporter)
+            provider.add_span_processor(processor)
+            trace.set_tracer_provider(provider)
+        else:
+            if not env_telemetry.lower() in ('none', 'false', ''): print(f"Warn: Invalid telemetry processor specified: {env_telemetry}")
+            # instanciate a no-op tracer
+            provider = NoOpTracerProvider()
+            trace.set_tracer_provider(provider)
+
+        # creates a tracer from the global tracer provider
+        self.tracer = trace.get_tracer("ARENA-py")
+        
+        # create a parent span
+        self.parent_span = self.tracer.start_span("somescene")
+        self.parent_span_ctx = trace.set_span_in_context(self.parent_span)    
+        
+        # make sure we end parent span
+        atexit.register(self.exit) 
+             
+    def exit(self, error_msg=None):
+        """Record exit status on error """
+        if not self.enabled: return
+        if error_msg: self.parent_span.set_status(Status(StatusCode.ERROR, error_msg))
+        self.parent_span.end()
+        self.exporter.force_flush()
+        self.parent_span = INVALID_SPAN
+        
+    def __del__(self):
+        if self.parent_span != INVALID_SPAN: self.exit()
+        
+    def start_span(self, name, **kwargs):
+        """Wrapper to otel start_as_current_span; force context to be parent span"""
+        if 'context' in kwargs: del kwargs['context']
+        return self.tracer.start_as_current_span(name, context=self.parent_span_ctx, **kwargs)
+
+    def start_process_msg_span(self, obj_id, action, **kwargs):
+        """Wrapper to otel start_as_current_span to start a process message span; force context to be parent span"""
+        if 'context' in kwargs: del kwargs['context']
+        return self.tracer.start_as_current_span(f"{obj_id} {action} process_message", context=self.parent_span_ctx, **kwargs)
+
+    def start_publish_span(self, obj_id, action, type, **kwargs):
+        """Wrapper to otel start_as_current_span to start a process message span; force context to be parent span"""
+        if 'context' in kwargs: del kwargs['context']
+        return self.tracer.start_as_current_span(f"{obj_id} {action} publish_message {type}", context=self.parent_span_ctx, **kwargs)
+        
+    def add_event(self, name, span=None, print_msg=True, **kwargs):
+        """Add event to given or current span"""
+        if print_msg: print(name)
+        if not self.enabled: return
+        if not span: span = trace.get_current_span()
+        if span == INVALID_SPAN: span = self.parent_span
+        span.add_event(name, kwargs)
+
+    def set_error(self, error_msg, span=None, print_msg=True):
+        """Set error on given or current span"""
+        if print_msg: print(error_msg)
+        if not self.enabled: return
+        if not span: span = trace.get_current_span()
+        if span == INVALID_SPAN: span = self.parent_span
+        span.set_status(Status(StatusCode.ERROR, error_msg))
diff --git a/arena/utils/cmd_interpreter.py b/arena/utils/cmd_interpreter.py
index 94471556..e6a90d53 100644
--- a/arena/utils/cmd_interpreter.py
+++ b/arena/utils/cmd_interpreter.py
@@ -1,7 +1,26 @@
-# A simple command interpreter
+"""
+The ArenaCmdInterpreter is a simple line-oriented command interpreter that
+allows to inspect library/program state. It looks at :envvar:`ENABLE_INTERPRETER` 
+to enable the interpreter. 
+
+The :class:`.ArenaCmdInterpreter` receives a :class:`.Scene` instance and provides commands
+to inspect attributes and execute functions (callables) given to the constructor.
+
+The commands available are:
+  show: displays attributes
+  info: excutes scene functions that output information
+  help: displays the commands available
+  exit: terminates the program
+  
+"""
 
 import cmd, os, json, asyncio, threading, time
 from datetime import date, datetime
+from ..env import (
+    ENABLE_INTERPRETER,
+    _get_env
+)
+
 class ArenaCmdInterpreter(cmd.Cmd):
     intro = 'Welcome to the arena-py console. Type help or ? to list available commands.\n'
     prompt = '# '
@@ -11,23 +30,25 @@ def __serialize_obj(self, obj):
         if isinstance(obj, (datetime, date)):
             return str(obj)
         if hasattr(obj, '__dict__'):
-            return obj.__dict__
+            return {k: v for k, v in vars(obj).items() if k.startswith("_") == False} # ignore private members
         raise TypeError("Type not serializable")
 
-    def __init__(self, scene, show_attrs=('config_data', 'scene', 'users', 'all_objects', 'msg_io'), get_callables=('persisted_objs', 'persisted_scene_option', 'writable_scenes', 'user_list')):
+    def __init__(self, scene, show_attrs=('config_data', 'scene', 'users', 'all_objects', 'msg_io'), get_callables=('persisted_objs', 'persisted_scene_option', 'writable_scenes', 'user_list'), start_cmd_event=None):
+        self.enable_interp = _get_env(ENABLE_INTERPRETER).lower() in ('true', '1', 't')
+        if not self.enable_interp: return
         super().__init__(completekey='tab')
         self._scene = scene
         self._show_attrs = show_attrs
         self._get_callables = get_callables
+        
+        # start interpreter thread
+        t = threading.Thread(name='interpreter_thread', target=self.__cmd_loop_thread, args=(start_cmd_event,))
+        t.start()
 
     def __cmd_loop_thread(self, start_cmd_event):
         if start_cmd_event: start_cmd_event.wait(5) # try to start cmd last; wait on event with timeout
         self.cmdloop()
 
-    def start_thread(self, start_cmd_event=None):
-        t = threading.Thread(name='interpreter_thread', target=self.__cmd_loop_thread, args=(start_cmd_event,))
-        t.start()
-
     def do_show(self, arg):
         if arg not in self._show_attrs:
             self.help_show()
@@ -69,7 +90,7 @@ def do_exit(self, arg):
             answer = input("This will terminate the ARENA program. Are you sure [Y/N]? ").lower()
         if answer == "y":
             print("Exiting...")
-            os._exit(0)
+            self._scene.exit(0)
 
         return True
 
diff --git a/arena/utils/program_info.py b/arena/utils/program_info.py
new file mode 100644
index 00000000..dc31e750
--- /dev/null
+++ b/arena/utils/program_info.py
@@ -0,0 +1,129 @@
+""" 
+Collect information about the program execution, such as 
+activity times and number of messages sent/received.
+"""
+
+from datetime import datetime
+import os
+import sys
+
+from ..event_loop import PersistentWorker
+from ..base_object import BaseObject
+from ..env import (
+    PROGRAM_STATS_UPDATE_INTERVAL_MS,
+    _get_env,
+    _get_arena_env
+)
+
+class GetPublicAttrsMixin():
+    def get_attrs(self, **kwargs):
+        """ Return object public members only """              
+        obj = {k: v for k, v in vars(self).items() if k.startswith("_") == False}
+        obj.update(kwargs)
+        return obj
+        
+class QueueStats(dict):
+    def __init__(self, rcv_queue_len, pub_queue_len):
+        self.rcv_queue_len = rcv_queue_len
+        self.pub_queue_len = pub_queue_len
+    
+class ProgramRunInfo(BaseObject, GetPublicAttrsMixin):
+    """Program Run Information; collect program execution information. """
+        
+    object_type = "run_info"
+
+    def __init__(self, evt_loop=None, queue_len_callable=None, update_callback=None, update_interval_ms=_get_env(PROGRAM_STATS_UPDATE_INTERVAL_MS), **kwargs):
+        """
+        Returns a `ProgramRunInfo`. If an event loop is passed, will setup a periodic task to 
+        update execution stats and perform a callback to notify of this update
+        
+        Args:
+            evt_loop: an event loop to which we add a periodic task to update program stats
+            queue_len_callable: callable that returns a queue stats object
+            update_callback: callback when stats are updated
+            update_interval_ms: interval of the periodic task to update program stats
+            kwargs: additional attributes to add to the program info, e.g. web host, scene, namespace
+        """
+        # add additional arguments 
+        for key, value in kwargs.items():
+            setattr(self, key, value)
+        
+        # program args, env
+        self.filename=sys.argv[0]
+        self.args=sys.argv[1:]
+        self.env=_get_arena_env()
+        
+        # run stats
+        self.create_time =  datetime.utcnow().isoformat()[:-3]+"Z"
+        self.last_active_time =  datetime.utcnow().isoformat()[:-3]+"Z"
+        self.rcv_msgs = 0
+        self.pub_msgs = 0
+        # init when a message is sent/received (so we can see if messages were never sent/received)
+        self.last_rcv_time = None
+        self.last_pub_time = None
+        # init when avg is computed
+        self.avg_rcv_msgs_per_sec = None
+        self.avg_pub_msgs_per_sec = None
+        
+        self._update_callback = update_callback
+        self._get_queue_len = queue_len_callable            
+
+        self._msg_rate_time_start = datetime.now()
+        self._rcv_msgs_start = self.rcv_msgs
+        self._pub_msgs_start = self.rcv_msgs
+        self._update_count = 0
+        
+        if evt_loop:
+            # update stats periodically if event loop is given
+            t = PersistentWorker(evt_loop, self._update_stats, interval=update_interval_ms)
+            evt_loop.add_task(t)
+    
+    def _update_stats(self):
+        """Update stats (called by periodic task); Execute callback if defined """
+        elapsed = datetime.now() - self._msg_rate_time_start
+        if elapsed.seconds < 1: 
+            return
+        
+        # compute moving avg of rcv and pub messages
+        N=5 # window
+        rcv_msgs = self.rcv_msgs - self._rcv_msgs_start
+        pub_msgs = self.pub_msgs - self._pub_msgs_start
+        self._rcv_msgs_start=self.rcv_msgs
+        self._pub_msgs_start=self.rcv_msgs
+        if elapsed.seconds > 0:
+            rcv_msgs_per_sec = rcv_msgs  / elapsed.seconds
+            pub_msgs_per_sec = pub_msgs  / elapsed.seconds
+            if self._update_count < N: 
+                if self.avg_rcv_msgs_per_sec == None:
+                    self.avg_rcv_msgs_per_sec = 0
+                    self.avg_pub_msgs_per_sec = 0
+                self._update_count = self._update_count + 1
+                N=self._update_count
+            self.avg_rcv_msgs_per_sec = round(self.avg_rcv_msgs_per_sec + (rcv_msgs_per_sec - self.avg_rcv_msgs_per_sec) / N, 2)
+            self.avg_pub_msgs_per_sec = round(self.avg_pub_msgs_per_sec + (pub_msgs_per_sec - self.avg_pub_msgs_per_sec) / N, 2)
+                    
+            # update queue lens if callable given
+            if self._get_queue_len:
+                for key, value in self._get_queue_len().items(): setattr(self, key, value)
+                
+            # update callback if given
+            if self._update_callback: 
+                self._update_callback(self)
+        
+    def msg_rcv(self):
+        """Update stats when a message is received """
+        self.last_rcv_time = datetime.utcnow().isoformat()[:-3]+"Z"
+        self.rcv_msgs = self.rcv_msgs + 1
+        self.last_active_time = self.last_rcv_time
+
+    def msg_publish(self):
+        """Update stats when a message is published """
+        self.last_pub_time = datetime.utcnow().isoformat()[:-3]+"Z"
+        self.pub_msgs = self.pub_msgs + 1
+        self.last_active_time = self.last_pub_time
+            
+    def add_program_info(self, adict):
+        """ Add program info to another dictionary """
+        adict[ProgramRunInfo.object_type] = self.get_attrs()
+        return adict
+        
\ No newline at end of file
diff --git a/examples/objects/landmarks.py b/examples/attributes/landmark.py
similarity index 62%
rename from examples/objects/landmarks.py
rename to examples/attributes/landmark.py
index ad1ba26a..bebfeb79 100644
--- a/examples/objects/landmarks.py
+++ b/examples/attributes/landmark.py
@@ -10,7 +10,7 @@
 
 @scene.run_once
 def make_box():
-    scene.add_landmark(
-        Box(object_id=object_id, position=(0,2,-2)), label="The Box")
+    scene.add_object(
+        Box(object_id=object_id, position=(0,2,-2), landmark=Landmark(label="The Box")))
 
 scene.run_tasks()
diff --git a/examples/objects/capsule.py b/examples/objects/capsule.py
new file mode 100644
index 00000000..feb56ec8
--- /dev/null
+++ b/examples/objects/capsule.py
@@ -0,0 +1,16 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+
+@scene.run_once
+def make_box():
+    my_cap = Capsule(
+        object_id="my_cap",
+        position=(0, 2, -3),
+        material=Material(color=(50, 60, 200)),
+    )
+    scene.add_object(my_cap)
+
+
+scene.run_tasks()
diff --git a/examples/objects/entity.py b/examples/objects/entity.py
new file mode 100644
index 00000000..e6804571
--- /dev/null
+++ b/examples/objects/entity.py
@@ -0,0 +1,13 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+@scene.run_once
+def make_box():
+    my_ent = Entity(
+        object_id="invisible_object",
+        position=(0,2,-3),
+    )
+    scene.add_object(my_ent)
+
+scene.run_tasks()
diff --git a/examples/objects/ocean.py b/examples/objects/ocean.py
new file mode 100644
index 00000000..b8a4b9fb
--- /dev/null
+++ b/examples/objects/ocean.py
@@ -0,0 +1,18 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+
+@scene.run_once
+def make_box():
+    my_ocean = Ocean(
+        object_id="my_ocean",
+        position=(0, 0.5, -3),
+        rotation=(90, 0, 0),
+        width=10,
+        depth=10,
+    )
+    scene.add_object(my_ocean)
+
+
+scene.run_tasks()
diff --git a/examples/objects/pcd.py b/examples/objects/pcd.py
new file mode 100644
index 00000000..83739eaa
--- /dev/null
+++ b/examples/objects/pcd.py
@@ -0,0 +1,19 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+
+@scene.run_once
+def make_bunny_pcd():
+    bunny_pcd = PcdModel(
+        object_id="bunny_pcd",
+        position=(0, 1, -3),
+        scale=(5, 5, 5),
+        pointSize=0.01,
+        pointColor="#7f7f7f",
+        url="/store/users/wiselab/pcd-models/bunny.pcd",
+    )
+    scene.add_object(bunny_pcd)
+
+
+scene.run_tasks()
diff --git a/examples/objects/roundedbox.py b/examples/objects/roundedbox.py
new file mode 100644
index 00000000..384829de
--- /dev/null
+++ b/examples/objects/roundedbox.py
@@ -0,0 +1,18 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+
+@scene.run_once
+def make_box():
+    my_box = Roundedbox(
+        object_id="my_box",
+        position=(0, 2, -3),
+        scale=(1.5, 1.5, 1.5),
+        color=(50, 60, 200),
+        radius=0.25,
+    )
+    scene.add_object(my_box)
+
+
+scene.run_tasks()
diff --git a/examples/objects/splat.py b/examples/objects/splat.py
new file mode 100644
index 00000000..ef71e948
--- /dev/null
+++ b/examples/objects/splat.py
@@ -0,0 +1,16 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+
+@scene.run_once
+def make_seal_splat():
+    seal_splat = GaussianSplatting(
+        object_id="seal_splat",
+        position=(0, 0.5, -3),
+        src="/store/splats/luma-seal.splat",
+    )
+    scene.add_object(seal_splat)
+
+
+scene.run_tasks()
diff --git a/examples/objects/threejs_scene.py b/examples/objects/threejs_scene.py
new file mode 100644
index 00000000..14689961
--- /dev/null
+++ b/examples/objects/threejs_scene.py
@@ -0,0 +1,16 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+
+@scene.run_once
+def make_test_scene():
+    test_scene = ThreejsScene(
+        object_id="test-scene",
+        position=(0, 1, -3),
+        url="/store/users/wiselab/threejs-scenes/simple_scene.json",
+    )
+    scene.add_object(test_scene)
+
+
+scene.run_tasks()
diff --git a/examples/objects/videosphere.py b/examples/objects/videosphere.py
new file mode 100644
index 00000000..b429aaba
--- /dev/null
+++ b/examples/objects/videosphere.py
@@ -0,0 +1,17 @@
+from arena import *
+
+scene = Scene(host="arenaxr.org", scene="example")
+
+
+@scene.run_once
+def make_videosphere():
+    my_videosphere = Videosphere(
+        object_id="my_videosphere",
+        position=(0, 0, 0),
+        radius=150,
+        src='store/users/wiselab/images/360falls.mp4',
+    )
+    scene.add_object(my_videosphere)
+
+
+scene.run_tasks()
diff --git a/setup.py b/setup.py
index 0467e9a9..49a5ec18 100644
--- a/setup.py
+++ b/setup.py
@@ -39,7 +39,8 @@
         "webcolors~=1.3",
         "google_auth_oauthlib~=1.0.0",
         "google-auth~=2.22.0",
-        "PyJWT~=2.4.0"
+        "PyJWT~=2.4.0",
+        "opentelemetry-exporter-otlp-proto-grpc>=1.21.0"
     ],
     classifiers=[
         "Programming Language :: Python :: 3",


Test patch:
diff --git a/system-tests/all-test.sh b/system-tests/all-test.sh
index 22b23791..ae1ebc2f 100644
--- a/system-tests/all-test.sh
+++ b/system-tests/all-test.sh
@@ -26,6 +26,7 @@ tests+=("python3 examples/attributes/animation_mixer.py")
 tests+=("python3 examples/attributes/clickable.py")
 tests+=("python3 examples/attributes/color.py")
 tests+=("python3 examples/attributes/goto_url.py")
+tests+=("python3 examples/attributes/landmark.py")
 tests+=("python3 examples/attributes/material.py")
 tests+=("python3 examples/attributes/morph.py")
 tests+=("python3 examples/attributes/physics_impulse.py")
@@ -43,9 +44,9 @@ tests+=("python3 examples/objects/cone.py")
 tests+=("python3 examples/objects/cylinder.py")
 tests+=("python3 examples/objects/dodecahedron.py")
 tests+=("python3 examples/objects/gltf.py")
+tests+=("python3 examples/objects/hands.py")
 tests+=("python3 examples/objects/icosahedron.py")
 tests+=("python3 examples/objects/image.py")
-tests+=("python3 examples/objects/landmarks.py")
 tests+=("python3 examples/objects/light.py")
 tests+=("python3 examples/objects/line.py")
 tests+=("python3 examples/objects/octahedron.py")
@@ -58,6 +59,7 @@ tests+=("python3 examples/objects/thickline.py")
 tests+=("python3 examples/objects/torus.py")
 tests+=("python3 examples/objects/torus_knot.py")
 tests+=("python3 examples/objects/triangle.py")
+tests+=("python3 examples/objects/ui.py")
 
 # tutorial example tests
 tests+=("python3 examples/tutorial/advanced.py")
@@ -75,12 +77,16 @@ tests+=("python3 examples/camera-tracer.py")
 tests+=("python3 examples/device-scene.py")
 tests+=("python3 examples/device-sensor.py")
 tests+=("python3 examples/earth-moon.py -mh $host -n $namespace -s $scene")
+tests+=("python3 examples/earth-moon-laser.py -mh $host -n $namespace -s $scene")
+tests+=("python3 examples/earth-moon-simple.py -mh $host -n $namespace -s $scene")
+tests+=("python3 examples/grab.py")
 tests+=("python3 examples/green-boxes.py")
 tests+=("python3 examples/guac.py -mh $host -n $namespace -s $scene")
 tests+=("python3 examples/hello.py")
 tests+=("python3 examples/laser-pointer.py")
 tests+=("python3 examples/nav.py")
 tests+=("python3 examples/pinata.py")
+tests+=("python3 examples/physics.py")
 tests+=("python3 examples/sound-control.py")
 tests+=("python3 examples/statue.py -mh $host -n $namespace -s $scene")
 tests+=("python3 examples/teleporter.py")
diff --git a/system-tests/rotate-camera.py b/system-tests/rotate-camera.py
new file mode 100644
index 00000000..29d84ef3
--- /dev/null
+++ b/system-tests/rotate-camera.py
@@ -0,0 +1,50 @@
+# rotate-camera.py
+#
+# Rotate cameras around the origin
+
+import math
+
+from arena import *
+
+
+radius = 10
+interval = 0
+sign = 1
+
+def user_join_callback(camera):
+    print(f"User found: {camera.displayName} [object_id={camera.object_id}]")
+
+
+scene = Scene(host="arena-dev1.conix.io", scene="test", debug=True)
+scene.user_join_callback = user_join_callback
+
+@scene.run_forever(interval_ms=17)
+def move_cams():
+    global interval, sign
+    for c in scene.users:
+        xp = radius*math.cos(interval)
+        zp = radius*math.sin(interval)
+        scene.manipulate_camera(
+            c,
+            position=(
+                xp,
+                3,
+                zp,
+            ),
+            #rotation=Rotation(0,0,0)
+        )
+        scene.look_at(
+            c,
+            # target={"x": 0, "y": 0, "z": 0}
+            # target=(0, 0, 0)
+            target=(xp, 0, zp)
+        )
+    if interval > 3.14:
+        sign = -1
+    elif interval < 0:
+        sign = 1
+
+    interval = interval + (0.01*sign)
+
+
+scene.run_tasks()