diff --git a/objtree.js b/objtree.js
new file mode 100644
index 0000000..175a424
--- /dev/null
+++ b/objtree.js
@@ -0,0 +1,194 @@
+const OBJTREE_OBJECT = 0;
+const OBJTREE_UNKNOWN = 1;
+const OBJTREE_COMPLEX_FUNCTION = 2;
+const OBJTREE_SIMPLE_FUNCTION = 3;
+const OBJTREE_PRIMARRAY = 4;
+const OBJTREE_VARIABLE = 5;
+const OBJTREE_TOODEEP = 6;
+
+const OBJTREE_NAMES = [
+  "[obj]", "[???]", "[fun]", "[fun]", "[arr]", "[var]", "[!!!]"
+];
+
+var objtree = function(target, {
+  maxlevel = 10,
+  grandparent = "",
+  exclude = []
+} = {}){
+  var excludeRules = exclude.map(rule => new RegExp(rule));
+  
+  var getObjectDesc = function(obj){
+    switch(typeof obj){
+      case "object":
+        if (obj === null){
+          return [ OBJTREE_VARIABLE, "(null)" ];
+        }
+        else if (Array.isArray(obj)){
+          if (obj.length === 0){
+            return [ OBJTREE_PRIMARRAY, "[]" ];
+          }
+          else if (obj.every(ele => {
+            let type = typeof ele;
+            return type === "boolean" || type === "number" || (type === "string" && ele.indexOf('\n') === -1);
+          })){
+            return [ OBJTREE_PRIMARRAY, "[ "+obj.join(", ")+" ]" ];
+          }
+        }
+        
+        return [ OBJTREE_OBJECT ]; // special handling
+
+      case "function":
+        if (Object.keys(obj).length === 0 && (!obj.prototype || Object.keys(obj.prototype).length === 0)){
+          return [ OBJTREE_SIMPLE_FUNCTION, obj.toString().match(/\((.*?)\)/)[0] ];
+        }
+        else{
+          return [ OBJTREE_COMPLEX_FUNCTION, obj.toString().match(/\((.*?)\)/)[0] ];
+        }
+
+      case "boolean":
+      case "number":
+      case "string":
+        return [ OBJTREE_VARIABLE, obj ];
+
+      case "undefined":
+        return [ OBJTREE_VARIABLE, "(undefined)" ];
+
+      default:
+        return [ OBJTREE_UNKNOWN, obj.toString() ];
+    }
+  };
+  
+  var generateTree = function(node, parents, level){
+    let res = {};
+    
+    for(let key in node){
+      if (excludeRules.length){
+        let fullKey = parents+key;
+        
+        if (excludeRules.some(rule => rule.test(fullKey))){
+          res[key] = {
+            type: OBJTREE_UNKNOWN,
+            value: "(excluded)"
+          };
+          
+          continue;
+        }
+      }
+      
+      let obj = node[key];
+      let [ type, value ] = getObjectDesc(obj);
+      
+      if (type === OBJTREE_OBJECT){
+        if (level > maxlevel){
+          type = OBJTREE_TOODEEP;
+          value = "(too deep)";
+        }
+        else{
+          value = generateTree(obj, parents+key+"/", level+1);
+        }
+      }
+      else if (type === OBJTREE_COMPLEX_FUNCTION){
+        let data = { __args: value };
+        
+        for(let fkey in obj){
+          data[fkey] = obj[fkey];
+        }
+        
+        if (obj.prototype){
+          data.prototype = {};
+          
+          for(let pkey in obj.prototype){
+            if (pkey !== "constructor"){
+              data.prototype[pkey] = obj.prototype[pkey];
+            }
+          }
+        }
+        
+        value = generateTree(data, parents+key+"/", level+1);
+      }
+      
+      res[key] = { type, value };
+    }
+    
+    return res;
+  };
+  
+  var tree = generateTree(target, "", 1);
+  
+  var obj = {
+    asObj: function(){
+      return tree;
+    },
+    
+    asText: function(){
+      var lines = [ "OBJECT TREE", "===========" ];
+      var grandpa = " "+(grandparent ? grandparent+"." : "");
+      
+      var sorter = function(entry1, entry2){
+        let v = entry1[1].type - entry2[1].type;
+        return v === 0 ? +(entry1[0] > entry2[0]) : v;
+      };
+      
+      var keyRegex = /^[a-z_$][a-z0-9_$]+$/i;
+      
+      var getKeyAccess = function(key){
+        return keyRegex.test(key) ? "."+key : "['"+key+"']";
+      }
+      
+      var varTypes = [
+        OBJTREE_VARIABLE, OBJTREE_PRIMARRAY, OBJTREE_UNKNOWN, OBJTREE_TOODEEP
+      ];
+      
+      var addLines = function(node, parents, level){
+        let entries = Object.entries(node);
+        let prefix = "  ".repeat(level)+"|-- ";
+        
+        let longest = Math.max.apply(null, entries
+          .filter(entry => varTypes.includes(entry[1].type))
+          .map(entry => (level === 0 ? entry[0] : getKeyAccess(entry[0])).length)
+        );
+        
+        for(let [key, desc] of entries.sort(sorter)){
+          let keyText = level === 0 ? key : getKeyAccess(key);
+          
+          if (desc.type === OBJTREE_OBJECT){
+            lines.push(prefix+grandpa+parents+keyText);
+            addLines(desc.value, parents+keyText, level+1);
+          }
+          else{
+            let commonPre = prefix+OBJTREE_NAMES[desc.type]+grandpa+parents+keyText;
+            
+            if (desc.type === OBJTREE_SIMPLE_FUNCTION){
+              lines.push(commonPre+desc.value);
+            }
+            else if (desc.type === OBJTREE_COMPLEX_FUNCTION){
+              lines.push(commonPre+desc.value.__args.value);
+              delete desc.value.__args;
+              addLines(desc.value, parents+keyText, level+1);
+            }
+            else{
+              lines.push(commonPre+(" ".repeat(longest-keyText.length))+" > "+desc.value);
+            }
+          }
+        }
+      };
+      
+      addLines(tree, "", 0);
+      return lines.join("\n");
+    },
+    
+    downloadText: function(filename){
+      let url = window.URL.createObjectURL(new Blob([obj.asText()], { "type": "octet/stream" }));
+      let ele = document.createElement("a");
+      document.body.appendChild(ele);
+      ele.href = url;
+      ele.download = filename;
+      ele.style.display = "none";
+      ele.click();
+      document.body.removeChild(ele);
+      window.URL.revokeObjectURL(url);
+    }
+  };
+  
+  return obj;
+};