TVScript is a high-level, object-oriented, statically typed, game or module scripting language. It uses an indentation-based syntax similar to languages like Python. Its goal is to be optimized for embedding into java game engines, but is licensed under the MIT license, so feel free to write a runtime for your language of choice. The language definition itself does not require that it be implemented in Java; that's just what I use.
- Fast TVScript is designed to be fast and efficient. Eventually this language will be compiled to JVM bytecode, so it should run just as fast as Java.
- Easy to learn TVScript is intended to be used as a beginner's language while being powerful enough that seasoned developers can still make useful and fast software.
- Easy to embed TVScript is designed to be embedded into a game engine, but it's also usable as a standalone language.
- Easy to read while remaining concise TVScript is designed to be readable by humans, it's language features are verbose where it matters and concise where possible.
- Strict and Safe TVScript is designed as a strongly typed language, and aims to reduce runtime errors by warning the developer if they are doing something that is likely to cause problems.
Currently, the language is under heavy development. Below is the implementation status of the features outlined in this spec.
- Basic Types (
integer,decimal,string,boolean,none,range) - Variable Definitions (
var,const, and explicit type) - Basic Expressions & Operators
- Indentation-based Block Scoping
-
if/elseStatements -
print&passStatements - Ternary Operator (
condition ? true : false) - String Interpolation (
"{expression}") -
while&forLoops (Ranges,break,continue) -
matchStatements - Functions & First-class functions
- Classes & Objects
- Inheritance & Traits
- Types & Operator Overloading
- Built-in Collections (
list,set,map) - Generics
- Error Handling (
try/catch/throw) - Async Execution (
async/await/launch) - Events & Event Listeners (
event/on/dispatch) - Annotations
- Native Classes
- Native Functions
- Module System (
import) - Script Visibility Modifiers
- Main Entrypoints (
main) - Bytecode Compilation (Currently Interpreted)
Some people learn better by taking a look at the syntax directly, keep reading for more details.
// Everything after a "//" is a comment.
// Tripple slash marks a block comment. Block comments must be closed with another tripple slash.
///
Like this
///
// A file is a script
// Optional imports for logic in other scripts
import path.to.script.OtherScript
// Variables are defined with a type first, then the name, then optionally a value
integer a = 10
boolean b = true
decimal c = 1.23
string d = "hello"
// constant variables are defined with the const keyword
const integer e = 12
// inferred types are also allowed
var f = 10 // The compiler will replace with with `integer`
const g = 12 // The compiler will replace with with `const integer`
//The main entrypoint of a script is defined by the main keyword
main:
sayHello()
doRandomThings(a: 12)
// Functions are defined with the function keyword
function sayHello():
//Statements belonging to the block must be indented
print "hello"
// Functions can take in parameters and return a value
function add(integer a, integer b) -> integer:
return a + b
//Function parameters can also have default values, this makes them optional as arguments
function doRandomThings(integer a, integer b = 20):
//Scope is limited to blocks
const integer localConst = 5
//conditionals are allowed
if a < localConst:
print a
else if b > 5:
print b
else:
print "fail"
//You can also use a range for loops
for 0..10:
print "printing"
for [integer i] in 0..10:
print i
while a != 0:
a--
// Lists are cool
list[integer] numbers = new list[] //Empty list
list[integer] filled = new list[](1, 2, 3)
integer foo = filled[0] //sets foo to 1
// Multi dimensional lists are also supported
list[integer][integer] twoDimensional = new list[][]
for [integer i] in filled:
print i
// Maps are cooler
map[string|integer] employeeAges = new map[|]("brad": 20, "samantha": 21)
integer bar = employeeAges["brad"] //sets bar to 20
for [string name | integer age] in employeeAges:
// String formatting
print "{name} is {age} years old"
match b:
3: print "three"
4:
print "four"
print "my lucky number!"
5..10: print "five thru ten"
default: print "no match"
// Classes
class Animal:
const string name
// You can optionally define a constructor to do some initialization
constructor():
this.name = "Animal"
enum Color:
RED
GREEN
BLUE
// Traits
trait EmitsSound:
default makeSound():
print "beep"
trait Flies:
fly()
// Inheritance
class Dog[EmitsSound, Flies] < Animal:
constructor(string breed):
super(name: breed) // Use super to call parent constructors
//Override default behavior with the override keyword
override makeSound():
super.makeSound() // Call parent method (not usually in this context, but like this)
print "woof"
//Implement undefined behaviour with the same keyword
override fly():
dispatch DeathEvent(deathMessage: "dogs can't fly")
@EditorHint(hint: "This is a hint that appears in the editor")
event DeathEvent:
string deathMessage
Color messageColor = Color.RED
on DeathEvent(string deathMessage):
print deathMessage
annotation EditorHint:
string hintIdentifiers are case-sensitive and must contain only letters (a to z and A to Z), digits (0 to 9) and/or underscores _.
The following words are reserved and cannot be used as identifiers:
| Keyword | Description |
|---|---|
import |
Imports logic from another script |
main |
Defines the main entrypoint of a script |
public |
Public visibility modifier |
private |
Private visibility modifier |
protected |
Protected visibility modifier |
module |
Module visibility modifier |
var |
Defines a variable |
const |
Defines a constant variable |
integer |
Defines an integer variable |
decimal |
Defines a decimal variable |
string |
Defines a string variable |
boolean |
Defines a boolean variable |
function |
Defines a function |
return |
Returns from a function |
if |
Defines an if statement |
else |
Defines an else statement |
for |
Defines a for loop |
while |
Defines a while loop |
match |
Defines a match statement |
default |
Defines a default case in a match statement |
break |
Breaks out of a loop |
continue |
Continues to the next iteration of a loop |
print |
Prints to the console |
none |
No value |
class |
Defines a class |
new |
Creates a new instance of a class |
trait |
Defines a trait |
type |
Defines a type |
operator |
Defines an operator overload |
this |
The current instance of the class |
super |
Calls the parent constructor or method |
override |
Overrides a method |
is |
Checks if an object is an instance of a type or object |
has |
Checks if an object has a trait |
as |
Cast a value to a different type |
list |
Defines a list |
set |
Defines a set |
map |
Defines a map |
enum |
Defines an enum |
event |
Defines an event |
on |
Defines an event handler |
dispatch |
Dispatches an event |
annotation |
Defines an annotation |
throw |
Throws an error |
throws |
Declares that a function throws an error |
try |
Defines a try block |
catch |
Defines a catch block |
async |
Defines an asynchronous function |
await |
Suspends execution until an asynchronous function returns |
launch |
Launches an asynchronous function without suspending |
all |
Used in await blocks for all-or-nothing completion |
timeout |
Used in await blocks to set a timeout |
pass |
A do nothing statement |
native |
Calls a native function |
The following operators are supported:
| Operator | Description |
|---|---|
( ) |
Grouping (Highest Priority) Not really an operator, but let you define precedence |
x[index] |
Subscription |
x.attribute |
Attribute access |
foo() |
Function call |
x.method() |
Method call |
x is y |
Checks if x is an instance of y |
x * y |
Multiplication |
x / y |
Division |
x % y |
Remainder (Modulus) |
x + y |
Addition |
x - y |
Subtraction |
x < y |
Less than |
x <= y |
Less than or equal to |
x > y |
Greater than |
x >= y |
Greater than or equal to |
x == y |
Equal to |
x != y |
Not equal to |
x and y |
Logical AND |
x or y |
Logical OR |
! |
Logical NOT |
condition ? trueExpression : falseExpression |
Ternary operator |
x = y |
Assignment |
x += y |
Add and assign |
x -= y |
Subtract and assign |
x *= y |
Multiply and assign |
x /= y |
Divide and assign |
x %= y |
Modulus and assign |
x++ |
Increment |
x-- |
Decrement |
x .. y |
Range |
| ` | ` |
< > |
Generic type parameters and arguments |
[ ] |
Indexing |
{ } |
String interpolation or block-like data |
: |
Block definition |
, |
Separator |
-> |
Function return type separator |
The following literals are supported:
| Literal | Description |
|---|---|
none |
No value |
true, false |
Boolean literals |
123 |
Integer literal |
123.456 |
Decimal literal |
"hello" |
String literal |
"""hello""" |
Triple Quoted String literal |
Scripts are expected to be organized in a hierarchical filesystem, and as such these visibility modifiers are derived by the scripts location in that filesystem.:
public: anything anywhere has accessprivate: only this block has accessprotected: all scripts in this folder have accessmodule: all scripts in this module have access (game engine specific) Given the following file structure:
modules/
module1/
scripts/
package1/
script1.tvs
script2.tvs
package2/
script3.tvs
module2/
scripts
package1/
script2.tvsclasses defined in script1.tvs belong to the module "module1" and the package "package1", and thus it's reference will be module1.package1.script1.ClassName. any subfolders in a package will also map to a new section in the reference separated by a period ., to modify a class visibility you just prefix any definition by the visibility modifier (the visibility modifier MUST come first in the list of modifiers).
modules/module1/scripts/package1/script1.tvs
public class ModInfo:
string gameVersion
private string hostName
module string mainEntrypoint
For the above definition any script can access ModInfo to create instances of it etc. Any script in the package1 folder can then get the gameVersion from that object, however the hostName field is accessible only to methods defined in the same block as the field (the class definition), similarly only scripts in the module1 folder can access the mainEntrypoint field.
In a regular environment the module visibility modifier will never be used, but when embedding this language into a game engine, it's useful.
By default, all classes, methods, and fields are private. (Planned behavior, not yet implemented.) Scripts are public, and there is currently no way to make a script private, you control the visibility of members individually.
to import some functionality from one script to another, you can use the import keyword at the start of your file
modules/module1/scripts/package2/script3.tvs
import module1.package1.script1.ModInfo
ModInfo modinfo = new ModInfo()
if paths to a script have conflicting names you can clarify the path or use as in the import
import some.package.here.ModInfo
import some.other.package.here.ModInfo //error, ModInfo already exists
//instead do
import some.package.here.ModInfo
ModInfo modinfo = new ModInfo()
some.other.package.here.ModInfo modinfo2 = new some.other.package.here.ModInfo()
//however this is ugly af, so you can also do this
import some.package.here.ModInfo
import some.other.package.here.ModInfo as OtherModInfo //you can use this if there is no conflict too
ModInfo modinfo3 = new ModInfo()
OtherModInfo modinfo4 = new OtherModInfo()
Import blocks are also supported so you can avoid repeating the module path:
import some.package.here:
ModInfo
OtherThing as AliasThing
helperFunction
For one-line imports, you can use brackets:
import some.package.here: [ModInfo, OtherThing as AliasThing, helperFunction]
Trailing commas are not allowed in either import block format.
Anything after a // is a comment. Comments are ignored by the compiler and are only for human readability.
//This is a comment
You can also define multiline comments like this
///
Multiline comment here
///
The following types are built in:
nonebooleanintegerdecimalstringlist[type]set[type]map[keyType|valueType]rangefunction
The none type is used to represent the absence of a value.
The boolean type is used to represent true or false.
The integer type is used to represent whole numbers. Equivalent to int in Java
The decimal type is used to represent floating point numbers. Equivalent to double in Java. Decimals are required to have a decimal point with a number before and after its definition. 0.1 is allowed, but .1 is not.
The string type is used to represent text. String literals are surrounded by double quotes. "hello" multiline strings are also allowed with tripple-quoted syntax.
"""
this is a multiline
string; yay!
"""
The list[type] type is used to represent a list of values of a specific type. More on this later.
The set[type] type is used to represent a set of unique values of a specific type. More on this later.
The map[keyType|valueType] type is used to represent a map of key value pairs of a specific type. More on this later.
The range type is used to represent a range of values. Defined by x..y where x is the start and y is the end of the range. Ranges are inclusive of both ends and are really only used for integers.
Functions are first-class citizens in TVScript. But since this is a somewhat advanced and nuanced feature, we will circle back to them later.
Variables are defined with a type first, then an identifier, then a value.
boolean a = true
integer b = 4
decimal c = 5.0
string d = "Some string"
Variables can also have their types inferred using the var keyword, meaning that if no type is specified, the type will be inferred from the value assigned to the variable. The type of the variable cannot be changed once it is defined even if the type is inferred. The following is equivalent to the definitions above.
var a = true
var b = 4
var c = 5.0
var d = "Some string"
Constant values can never be changed once they are defined, and must be assigned when they are defined. You define a constant with the const keyword before the type, or if you want to infer the type.
const boolean a = true
a = false //error
const b = 4
b = 5 //error
Expressions evaluate to a value. They can be any combination of literals, variables, and constants. 4 + 6 is an expression that evaluates to an integer of value 10. etc.
Expressions can be define inline with curly braces. For example string a = "four plus six is {4 + 6}"
Statements are delineated by newlines, but can span to multiple lines if the line ends in an operator. Statements perform actions, and these actions vary by context. For example, a print statement prints the following expression to the console as a string.
Blocks are used to group statements together. Blocks are delineated with a : and a newline, and separate scopes of variables to that block. Anything belonging to a block must be indented to fall under that block. All blocks must contain at least one statement. The pass statement can be used to fulfill this rule without performing any actions.
something: //: defines a block (something is not a keyword, this is an example)
print "hello" //statements belonging to the block must be indented
Blocks with only a single statement can be written on one line the above example could be written as:
something: print "hello"
Lists are a generic sequence of object or value types including other lists or maps. Lists are always indexed starting at 0. Negative indices count from the end.
To define a list you use the list[type] syntax.
list[integer] numbersE = new list[] //Create a totally empty list
var numbers = new list[] //ERROR, unknown type
list[integer] numbers = new list[10] //Initial size of 10 none value placeholders
list[integer] filled = new list[](1, 4, 5, 12, 12) //init the list with some known start values
To access a value in a list, you can index it with square brackets.
list[integer] integerList = new list[](1, 2, 3, 4, 5)
integer firstElement = integerList[0] //sets firstElement to 1
integer lastElement = integerList[-1] //sets lastElement to 5
integerList[0] = 10 //List is now (10, 2, 3, 4, 5)
integerList[-1] = 15 //List is now (10, 2, 3, 4, 15)
var exampleList = new list[](1, 2, 3, 4)
integer size = exampleList.size //sets size to 4
list[integer] example = new list[](1, 2, 3, 4, 5)
//You can't set an element in a list if it doesn't exist
example[5] = 6 //error
//You can add an element to a list like this
example.add(6) //sets example to (1, 2, 3, 4, 5, 6)
//You can insert an element at a specific index
example.insert(2, 15) //sets example to (1, 2, 15, 3, 4, 5, 6)
//You can remove an element at a specific index
integer removed = example.remove(2) //sets example to (1, 2, 3, 4, 5, 6) and returns and sets removed to 15
//You can remove the last element
integer popped = example.pop() //sets example to (1, 2, 3, 4, 5) and returns and sets popped to 6
//You can clear a list
example.clear() //sets example to ()
//You can reverse a list
example.reverse() //sets example to (5, 4, 3, 2, 1)
//You can determine if a list contains a specific value
boolean contains = example.contains(5) //sets contains to true
//You can obtain sublists from a list
list[integer] example2 = new list[](1, 2, 3, 4, 5)
list[integer] sublist = example2[1..3] //sets sublist to (2, 3, 4)
list[integer] sublist2 = example2[1..] //sets sublist2 to (2, 3, 4, 5)
list[integer] sublist3 = example2[..3] //sets sublist3 to (1, 2, 3, 4)
Sets are a generic collection of unique object or value types. Sets do not have a defined order and cannot contain duplicate elements.
To define a set you use the set[type] syntax.
set[integer] numbersE = new set[] //Create an empty set
set[integer] filled = new set[](1, 4, 5, 12, 12) //init the set with some known start values, duplicates are ignored
var exampleSet = new set[](1, 2, 3, 4)
integer size = exampleSet.size //sets size to 4
set[integer] example = new set[](1, 2, 3, 4, 5)
//You can add an element to a set
example.add(6) //sets example to (1, 2, 3, 4, 5, 6)
//Adding an existing value has no effect
example.add(6) //example remains (1, 2, 3, 4, 5, 6)
//You can remove an element from a set
example.remove(5) //sets example to (1, 2, 3, 4, 6)
//You can clear a set
example.clear() //sets example to ()
//You can determine if a set contains a specific value
boolean contains = example.contains(6) //sets contains to true
Maps are a generic key value pairing of object or value types. You define a map similarly to a list, but you need two types map[keyType | valueType]
map[string|integer] employeeAgesE = new map[|] //Create and empty map
map[string|integer] employeeAges = new map[|]("brad": 20, "samantha": 21, "craig": 80) //Some initial values
integer bradsAge = employeeAges["brad"] //sets variable bradsAge to 20
employeeAges["brad"] = 21 //Updates brad's age in the map to 21
integer size = employeeAges.size
boolean hasBrad = employeeAges.containsKey("brad")
integer removedAge = employeeAges.remove("brad")
list[string] keys = employeeAges.keys()
list[integer] values = employeeAges.values()
employeeAges.clear() //removes all entries
Loops are used to execute similar logic a number of times or general iteration.
A range is a special type in TVScript so you can define them as an expression in for loops instead of needing to define it like in java
for 0..10:
print "hello"
If you need to track the current iteration, you can pass a variable to the loop
for [integer i] in 0..10:
print i
while condition:
print "hello indefinitely"
for [string value] in someList:
print value
for [string value] in someSet:
print value
for [string key | string value] in someMap:
print "{key} = {value}"
If statements are used to execute logic based on a condition.
if condition:
pass //If condition evaluates to true this block will be executed
else if condition2:
pass //If condition is false and condition2 is true this block will be executed
else:
pass //If condition is false and condition2 is false this block will be executed
boolean value = condition ? trueValue : falseValue
aka switch statements in java
match someString:
"hello": print "matches hello"
"world": print "matches world"
Functions are blocks of code that can be called as a statement in other parts of the code.
function sayHello():
print "hello"
functions can also take in parameters and be passed arguments. All parameters must define a type:
function greet(string name):
print "greetings {name}!"
Functions can have optional arguments by providing a default value in the function definition.
function greet(string name = "programmer"):
print "greetings {name}!"
If an argument is not provided when calling the function, the default value will be used.
greet() //Prints "greetings programmer!"
greet(name: "Junie") //Prints "greetings Junie!"
functions can also be used as expressions by returning a value. The return type must be specified if a return is used.
function add(integer a, integer b) -> integer:
return a + b
If no return type is specified, the function is assumed to return none. You can specify this as the return type if you feel so inclined, but it is not required. In our earlier example: function greet(string name): is equivalent to function greet(string name) -> none:.
You call a function by name
sayHello() //Prints "hello" to the console
greet(name: "keith") //Prints "greetings keith" to the console
var sum = add(a: 10, b: 20) //sets sum to 30
Classes are like templates for objects in your scripts. They hold some data and let you operate on that data. You can define classes with the class keyword followed by the class name. Class names are usually capitalized.
class Player:
string name
constructor(string name):
this.name = nameTo create an instance of an object you call its constructor like this:
Player player = new Player(name: "joe")Note: A class must define at least one constructor to be instantiated. If no constructor is defined, it is a compile error.
You can define custom constructors for your classes with the constructor keyword.
class Player:
string name
integer health
constructor(string name, integer health = 100):
this.name = name
this.health = health != none ? health : 100
The this keyword is used to refer to the current object. You might have seen it used in the constructor above. It just refers to the current object.
Methods are like functions that belong to a class.
class Vector2d:
decimal x
decimal y
constructor(decimal x, decimal y):
this.x = x
this.y = y
//Adds a vector to this vector and returns a new value
//Note that methods are not functions per-se, so we don't use the function keyword here
add(Vector2d delta) -> Vector2d:
return new Vector2d(x: this.x + delta.x, y: this.y + delta.y)Classes can define multiple constructors to allow different ways of creating an object. The interpreter will select the best matching constructor based on the named arguments provided. Usually this means that the constructor with the fewest unused named arguments is selected.
class Player:
string name
integer score
constructor(string name):
this.name = name
this.score = 0
constructor(string name, integer score):
this.name = name
this.score = score
Player p1 = new Player(name: "Junie")
Player p2 = new Player(name: "Robot", score: 100)You can also define functions inside a class using the function keyword. These functions do not have access to this and are called on the class name directly.
class MathUtils:
constructor(): pass
function add(integer a, integer b) -> integer:
return a + b
print MathUtils.add(a: 10, b: 20)This allows you to define an optional default value for a field when it is not set in a constructor.
class Vector2d:
decimal x = 0
decimal y = 0
TVScript also allows the embedded application to expose native classes and methods to your scripts. These can be used directly in your scripts, providing a bridge between the TVScript environment and the underlying application. To define a native class you prefix the class definition with the native keyword, and do the same with methods and an empty body.
For the following Java class:
package com.example.tvscript;
public class Vector2d {
public static final Vector2d UNIT_VECTOR = new Vector2d(1, 0);
int x;
int y;
public Vector2d(int x, int y) {
this.x = x;
this.y = y;
}
public Vector2d add(Vector2d delta) {
return new Vector2d(x + delta.x, y + delta.y);
}
}you define the TVScript mapping like this:
native class com.example.tvscript.Vector2d as Vec2:
native integer x
native integer y
native const Vec2 UNIT_VECTOR
native constructor(integer x, integer y)
native add(Vec2 delta) -> Vec2
All elements of the native class must be implemented in TVScript to be accessible in your scripts. This allows the api developer to determine what is safe to expose to your scripts. Api developers can also define functionality to these classes which does not exist in the embedded application.
native class com.example.tvscript.Vector2d as Vec2:
native integer x
native integer y
native const Vec2 UNIT_VECTOR
native constructor(integer x, integer y)
native add(Vec2 delta) -> Vec2
//Functionality unique to the TVScript environment
subtract(Vec2 delta) -> Vec2:
return new Vec2(x: x - delta.x, y: y - delta.y)
A type sounds like the same things as a class, but it has a few nuances for advanced users. Defining a type essentially registers a new primitive type that can be used like any other primitive type with operator overloads. These are used for storing and manipulating data in your scripts directly instead of in containers like classes. You define a type a lot like a class with the type keyword. Lets redefine Vector2D as a type:
type vector2d: //type names are usually lowercase to differentiate them from classes
decimal x
decimal y
You can initialize a type like this:
vector2d v = new vector2d(x: 10, y: 10)
You initialize a type using the new keyword, just like a class. Note that type fields are all constants and cannot be modified. That's where operator overloading comes in.
Operator overloading allows you to define custom behavior for operators like +, -, *, /, %, and comparisons. This is done by defining methods with special names that correspond to operators. To define an operator overload you prefix the method name with the operator keyword.
Supported operator method names:
add(+)subtract(-)multiply(*)divide(/)modulo(%)negative(unary-value)compare(used for==,!=,<,>,<=,>=)
If you explicitly declare parameters, their names must be left and right for binary operators, and right for negative. Parameter and return types can be inferred when omitted. Operator arguments and return type are not required to match the enclosing type, so mixed-type overloads (for example vector2d * decimal) are valid.
compare must return a decimal, where 0.0 means equal, a value less than 0.0 means left is less than right, and a value greater than 0.0 means left is greater than right.
type vector2d:
decimal x
decimal y
operator add(vector2d left, vector2d right) -> vector2d:
return new vector2d(x: left.x + right.x, y: left.y + right.y)
//You don't need to specify the parameter types or the return type for operator methods, it's inferred by the compiler
operator subtract(left, right):
return new vector2d(x: left.x - right.x, y: left.y - right.y)
operator multiply(vector2d left, decimal right) -> vector2d:
return new vector2d(x: left.x * right, y: left.y * right)
type score:
decimal value
operator compare(score left, score right) -> decimal:
return left.value - right.value
Now you can do things like:
vector2d v1 = new vector2d(x: 10, y: 10)
vector2d v2 = new vector2d(x: 5, y: 5)
vector2d sum = v1 + v2 //results in new vector2d(x: 15, y: 15)
vector2d scaled = v1 * 2.5
score a = new score(value: 1.0)
score b = new score(value: 2.0)
print a < b // true (via operator compare)
//However in our above definition you can't do:
vector2d div = v1 / v2 //runtime error: no operator overload defined for "divide" between vector2d and vector2d
Inheritance allows you to create a new class that inherits from another class. Currently, TVScript does not allow direct inheritance for types. You can, however, define traits that types can implement (we'll go over those next). You can override methods from the parent class with the override method.
class Entity:
string name
onSpawn():
print "spawned {name}"
class Player < Entity:
//inherited string name field
integer health = 10
override onSpawn(): //override methods from the parent class
print "player spawned {name}"
...
Entity entity = new Entity(name: "joe")
Player player = new Player(name: "momma")
print entity.name //prints "joe"
print player.name //prints "momma"
print player.health //prints 10
print entity.health //error, undefined field (Entity does not have a field named "health")
Traits define common behavior between implementing classes (or other traits). They allow you to define common methods, or functions that all classes with this trait implement (useful for categorizing different classes by their common parts)
trait EmitsSound:
playSound()
trait CanDie:
default checkForDeath(integer health):
if health <= 0:
print "dead"
trait Animal < [EmitsSound, CanDie]:
eat()
Unlike Classes where you can only extend one, Classes and types can implement any number of traits. For classes, put traits immediately after the class name and put the optional superclass after <. All methods defined in all traits implemented by a class MUST be defined in the class. Exceptions to this are default methods of the trait. You only need to override those if you want to.
class Player[EmitsSound, CanDie] < Entity:
//Even though there was no default functionality, you still need to override the parent method.
override playSound():
print "beep"
override checkForDeath(integer health):
//If you don't want to override the default functionality, just add to it you can just call the original method like this
super.checkForDeath(health)
print "your score was {score}"
Good design would prevent this, but if two traits are implemented by a class, and those traits have methods of the same name, the implementing class is REQUIRED to override that method since otherwise the method will be ambiguous. If you need to call the super method, you will need to clarify it by including the interface name in the super call TraitName.super.whateverMethodNameHere()
class Human:
const string name
class Dude < Human:
constructor(string name): super(name: name)
sup():
print "sup"
...
class Gurl < Human:
constructor(string name): super(name: name)
heyy():
print "heyy"
...
Human billy = new Dude(name: "billy")
Human sally = new Gurl(name: "sally")
boolean isHuman = billy is Human //true because dude extends human
billy.sup() //ERROR because the variable billy is of type Human; not Dude
Dude dudeVar = billy as Dude
dudeVar.sup() //prints "sup"
//Alternatively you can do this
if billy is Dude -> dudeBilly: //Creates a new variable dudeBilly of type Dude
dudeBilly.sup() //prints "sup"
This works the same for traits with the 'has' operator:
class SomeThing[ATrait]:
...
function checkHasTrait(object obj) -> boolean:
return obj has ATrait
Enumerations are what they describe, a set list of allowable values and potentially some associated data
enum ServerStatus:
OFFLINE //Enumeration fields are always capitalized
ONLINE
if getServerStatus() == ServerStatus.OFFLINE:
print "shutting down server"
You can also create enum constructors if you want to carry some more data with the enum other than just its name:
enum HorizontalLayout(integer direction): //fields will be automatically generated. Note that enum fields are always constant, and all fields must be defined.
LEFT(-1)
MIDDLE(0)
RIGHT(1)
print HorizontalLayout.LEFT.direction
The primary way to execute a script is through it's main entrypoint, however there are more than one entrypoint type. The next section goes over events, which are a special type of entrypoint that are dispatched by an embedded engine. The main entrypoint however is defined as follows:
main(list[string] arguments):
for [string arg] in arguments:
print arg
This takes in some console arguments and prints them to the console. If you don't have any console arguments, you can omit the parenthesis entirely.
main:
pass
Events are defined similarly to the main entrypoint, but with the event keyword. Events typically have an "Event" suffix. Events can carry some data with them defined as fields, just like classes.
event PlayerJoinedEvent:
Player player
Events do not need the new keyword and are dispatched immediately to any listeners or entry points. Dispatch an event with the dispatch keyword.
dispatch PlayerJoinedEvent(player: aPlayerObject)
Events act as a sort of entrypoint to the script, and can be listened to by defining a block in the root of the script using the on keyword. Any data associated with the event that you want to use in your event must be specified in the event definition.
on PlayerJoinedEvent(Player player): //parameter names must match the names of the fields in the event definition
print "Welcome to the server {player.name}"
Game engines are encouraged to define their own events that are dispatched by the engine and can be listened to by scripts.
Events can be dispatched with a pattern match. This lets you filter before any code in the event block is executed.
Let's take a player death event, maybe you only want to listen to deaths from a specific cause. Just clarify the event field with a known value that you're searching for, and the block of code will only be executed if the event matches that value.
class Player:
string name
integer level
...
enum DeathReason:
STABBED
SHOT
SOMETHING_ELSE
class Cause:
DeathReason reason
Player killer
...
on PlayerDeathEvent(Player player, Cause cause: cause.reason == DeathReason.STABBED):
print "{player.name} was killed by a knife!"
You can also chain together multiple expressions as long as they can evaluate to a boolean.
on PlayerDeathEvent(Player player, Cause cause: cause.reason == DeathReason.STABBED && cause.killer.level < 10):
print "{player.name} was killed by a knife by {cause.killer.name}, that's embarrasing since they're only level {cause.killer.level}"
Annotations don't really have a purpose in the language itself other than that they allow you to flag things. However, for game engine use these may define some behavior in the engine editor that would otherwise not be able to be derived
annotation EditorTooltip:
string tooltip = "no tooltip specified"
//Will use the default value for the tooltip
@EditorTooltip
type vector2d:
...
@EditorTooltip("Represents a 3d point in world space")
type vector3d:
...
Beyond this point are advanced features not recommended for beginner use. If you're just getting started, I would recommend you skip this section. If you've been programming for a while, or have used other languages before keep reading; there is a lot of juicy stuff beyond.
Optionals are a way to define an object as potentially being none.
Optionals in tvscript are defined by suffixing the type with ?
class Person:
string firstName
string? middleName //The middle name is optional
string lastName
//Maybe get a player from a database entry
Player? optionalPlayer = getPlayerFromDatabase(name: "playername")
Optionals can be evaluated as booleans where false means the value is none by suffixing the variable name with a ? in the if statement:
if optionalPlayer?:
print optionalPlayer.firstName
If you don't care if the value is none, you can just use the value directly and return none if it is none
string name = optionalPlayer?.firstName
You can also set a value to something if the optional is set or use a default using || and &&
//Evaluates to "no name" if player is none
string name = player?.firstName || "no name"
//results in none if player is none or the name of the player if player is set
string name = player && player.middleName
You can also unwrap an optional in conditionals
if optionalPlayer ? player:
print player.name
Functions are first-class citizens in tvscript. This means that they act similarly to other values in the language.
Functions can be assigned to variables
const squareFunction = function square(integer num) -> integer:
return num * num
Functions can be parameters of other functions
function apply(list[integer] numbers, function funcArg(integer num) -> integer):
list[integer] newNumbers = new list[]
for [integer num1] in numbers:
newNumbers.add(funcArg(num: num1))
return newNumbers
Functions can be passed as arguments to other functions
list[integer] numbers = new list[](1, 2, 3, 4)
list[integer] squareNumbers = apply(numbers: numbers, funcArg: squareFunction) //sets squareNumbers to (1, 4, 9, 16)
Optionally, you can pass an inline function as a parameter as long as it is a single statement. Inline functions are not required to be named.
list[integer] numbers = new list[](1, 2, 3, 4)
list[integer] squareNumbers = apply(numbers: numbers, funcArg: (integer num) -> num * num) //sets squareNumbers to (1, 4, 9, 16)
Functions can be defined inside other blocks or functions, their scope is limited to the block they are defined in.
function squareList(list[integer] numbers) -> list[integer]:
//Inline function
function square(integer num) -> integer:
return num * num
list[integer] newNumbers = new list[]
for [integer num] in numbers:
newNumbers.add(square(num))
return newNumbers
Functions can return functions
function makeMultiplier(integer factor) -> (integer x) -> integer:
return (integer x) -> x * factor
const double = makeMultiplier(factor: 2)
const result = double(x: 5) // 10
You can store functions in maps, sets and lists
map[string | (integer num) -> integer] operations = new map[|](
"square": squareFunction, //Function variable
"double": (integer num) -> integer: return num * 2 //Inline function
)
//Retrieving a function from a map or list can be invoked like any other function
result = operations["double"](num: 5) //10
A note about function types: Function parameter names are a part of the function type: (integer num) -> integer does not equal (integer x) -> integer. Function return types are also part of the type: (integer num) -> integer does not equal (integer num) -> string.
Generics let you define reusable behavior without hard-coding one concrete type.
You can use either inferred type arguments or explicit type arguments.
function identity<T>(T value) -> T:
return value
print identity(value: 7) // inferred T = integer
print identity<decimal>(value: 2.5) // explicit T = decimal
print identity<string>(value: "hello")
Use ~ for generic constraints. Trait constraints must use bracketed form.
function trigger<T ~ Animal>(T animal):
print animal.name
function trigger<T ~ Animal[MakesSound]>(T animal):
animal.makeSound()
print animal.name
function triggerDevice<T ~ [MakesSound]>(T device):
device.makeSound()
That syntax supports:
- one optional superclass constraint
- zero or more trait constraints in brackets
- unconstrained generic parameters (for example
function id<T>(T value) -> T)
You can define reusable generic constraints with a top-level constraint declaration.
constraint Cageable = Animal[MakesSound]
constraint AnimalOnly = Animal
constraint AnimalWithNoExtraTraits = Animal[]
constraint DeviceLike = [Named, MakesSound]
class Cage<T ~ Cageable>:
T animal
Constraint aliases can be used anywhere inline generic constraints are allowed (classes, functions, methods, and types).
For generic return types, use the generic type name directly.
function passThrough<T ~ Animal>(T animal) -> T:
animal.makeSound()
return animal
Now that covers the syntax, lets use a full class example. If a class has both generics and traits, generics always come first:
trait MakesSound:
string makeSound()
class Animal:
string name
constructor(string name):
this.name = name
class Dog[MakesSound] < Animal:
constructor(string dogName): super(name: dogName)
override makeSound(): bark()
bark(): print "woof"
class Cat[MakesSound] < Animal:
constructor(string catName): super(name: catName)
override makeSound(): meow()
meow(): print "meow"
Let's implement a generic cage class which can house any animal that makes sounds:
class Cage<T ~ Animal[MakesSound]>:
T animal
constructor(T animal): this.animal = animal
kickCage():
print "kicking cage..."
animal.makeSound()
print "The {animal.name} didn't like that, shame on you!"
Any animal that makes a sound can be put in the cage, and you can safely use members guaranteed by constraints.
Collections support parameterized element/key/value types and are enforced at runtime for mutation operations.
function returnOddIndexedItems<T>(list[T] items):
list[T] oddIndexedItems = new list[]
for [integer i] in 0..items.size - 1:
if i % 2 != 0: oddIndexedItems.add(items[i])
return oddIndexedItems
Runtime checks include:
list[T]element mutation (add,insert, index assignment)set[T]mutation (add)map[K|V]index assignment (map[key] = value)
Generic misuse is validated with clear diagnostics:
- Wrong type argument count at call/new/declaration sites is reported as a compile error.
- Constraint violations are reported as a compile error.
- Invalid generic collection mutations are reported as runtime errors with expected vs actual type info.
- Generic declarations and type arguments are supported for functions, classes, variable type annotations, and collection types.
- Type argument inference is supported from call/new arguments.
- Explicit type arguments are supported for calls/new (
fn<T>(...),new Box<T>(...)).
Errors are a way to signal that something went wrong. Sometimes this is expected, and sometimes it is not. When an error occurs, it is important to handle it in a way that makes sense for your program. There are two main ways to handle errors: using try-catch blocks or using error handling functions. First lets look at how to define your own error types and throw them when something goes wrong.
Errors are a special type of class that can be thrown, we define them in nearly the same way as classes, but with the error keyword and no traits allowed.
error FileNotFoundError < Error: //All errors must extend some base error type, Error is the base error type
string path
While most of the time throwing errors is expected to exit the program, sometimes you want to handle errors gracefully. For functions that may result in an error, and that you want to prompt users to be able to gracefully handle those errors, you can mark a function as throwing one or many errors.
function readFile(string path) throws FileNotFoundError -> string:
if !fileExists(path: path):
throw FileNotFoundError(path: path)
else:
return "some data"
If you want to suggest handling multiple errors to your user you can define them in a box separated by bars.
function readFile(string path) throws [FileNotFoundError | IOException] -> string:
...
As you can see, we can throw an error by calling the throw keyword followed by the error constructor. We can also mark a function as throwing an error by adding throws to the function definition. You don't have to mark the function as throwing an error if the function handles the error internally. Marking it as throws means that the api expects the user to handle the error themselves. Let's look at how to do that with a try block:
try:
string fileContents = readFile(path: "somefile.txt")
print "file contents: {fileContents}"
catch FileNotFoundError(string path):
print "File not found at path: {path}"
Note, not all errors need to be caught by uses in the try/catch block, if an error is not caught, it will be propagated up the call stack as normal.
Asynchronous execution is a way to allow the execution of code to continue while other code is being executed.
Consider the following function and event:
function getPlayerDataFromDatabase(integer id) -> Player:
return ...
on PlayerJoinedEvent(Player player):
PlayerData playerData = getPlayerDataFromDatabase(id: player.id)
print "Player {player.name} joined the server"
In this example the function getPlayerDataFromDatabase will block execution of anything else until it returns. This can be a problem if the function takes a long time to complete, as it will prevent other code from running. To avoid this, you can use asynchronous execution to allow the function to run in the background while other code continues to execute.
async function getPlayerDataFromDatabase(integer id) -> Player:
return ...
Then in the event listener we can do:
on PlayerJoinedEvent(Player player):
//The await keyword suspends execution of the current thread until the asynchronous function returns without stopping other actions
PlayerData playerData = await getPlayerDataFromDatabase(id: player.id)
print "Player {player.name} joined the server"
Note that the return type of an asynchronous function is a special Task<T> type. So when a function is declared as asynchronous, the return type is automatically boxed into the Task<T> type. When you use the await keyword the value is automatically unboxed into the type of the function. This means you can do this:
on PlayerJoinedEvent(Player player):
Task<PlayerData> task = getPlayerDataFromDatabase(id: player.id)
PlayerData playerData = await task
print "Player {player.name} joined the server"
If a task is never awaited, you can use the methods defined on Task<T> like any other class.
If there is no return value, or you want to just fire and forget the task, you can use the launch keyword instead of await. This will still execute the function in a non-blocking manner but doesn't suspend the function or anything like that.
async function saveGame():
...
//Later
launch saveGame()
If an async function results in an error, the error will be propagated to the caller on the current thread.
If you want to concurrently await multiple async functions, you can use an await block. Say you need to fetch data from multiple databases:
on PlayerJoinedEvent(Player player):
//Waits for all the async functions to return before continuing
//Creates all parameters as variables in the same scope as the await block
await (Data1 data1, Data2 data2):
//Note that you don't need to specify await on each function call, it is inferred by the compiler
data1 = getPlayerDataFromDatabase1(id: player.id)
data2 = getPlayerDataFromDatabase2(id: player.id)
//Will suspend execution until await block above is complete
print "{data1} and {data2}"
Note that all of these expressions in this block are evaluated in parallel, and that the order of evaluation is not guaranteed.
Also note that not all functions in an await block need to be async functions.
Await blocks also have a few other benefits, such as setting timeouts:
await (Data data) timeout 10s:
data = getSomeData() //Will suspend execution for 10 seconds if getSomeData() does not return within that time.
In the above example if getSomeData() does not return within 10 seconds, data will be set to none. If you want to set a default value for the variable, just add a default block below it:
await (Data data) timeout 10s:
data = getSomeData()
data2 = getSomeMoreData()
default:
data = defaultData
//Note not all values need to be assigned a default value, if getSomeMoreData() does not return within 10 seconds, data2 will be set to none.
By default, there is no timeout, and any values successfully evaluated within the timeout specified will be returned by their evaluated values, if you want an all-or-nothing timeout, add all to the await block. This will cause all values be assigned their default value if a timeout is reached:
await all (Data1 data1, Data2 data2) timeout 10s:
data1 = getSomeData1()
data2 = getSomeData2()
default:
data1 = defaultData1
data2 = defaultData2
If any of the async functions in the await block throw an error, you can catch it with a try await block:
try await (Data data):
data = getSomeData()
default: //Default still allowed
data = defaultData
catch (ErrorType1 error):
//OR handle error here
print error
catch (ErrorType2 error):
pass //Ignore this type of error
The order of the default and catch blocks is not important, the only requirements is that the await block must be first.
TVScript uses value semantics for primitives and types, but reference semantics for objects.
When passing primitive types like integer, decimal, boolean, string, or user defined types a change to the parameter inside the function does not affect the original variable.
See the following example:
function increment(integer x):
x = x + 1
integer a = 5
increment(x: a)
print a // prints 5
To implement the above function, you'd need to do this:
function increment(integer x) -> integer:
return x + 1
integer a = 5
a = increment(x: a)
print a // prints 6
When passing an object, you are passing the reference to that object by value. This means you can modify the contents of the object, and those changes will be reflected outside the function. However, reassigning the parameter to a new object will not affect the original reference.
class Counter:
integer count = 0
function incrementCounter(Counter c):
c.count = c.count + 1
function reassignCounter(Counter c):
c = new Counter()
c.count = 10
Counter myCounter = new Counter()
incrementCounter(c: myCounter)
print myCounter.count // prints 1
reassignCounter(c: myCounter)
print myCounter.count // still prints 1
Native functions and classes allow you to bind some script logic to native java functionality, usually done with the native keyword. The design methodology for native functionality is to allow most of the configuration to be done on the java side as a library to the global environment rather than most of the work being done in TVScript. This is because as a language designed to be embedded into java programs, we expect the maintainers of these programs to also know java.
TVScript has a native function API; you pass TVScriptNativeFunction definitions into the global environment of the embedded application. Embedders must build and provide their own global environment. Below is an example of two native functions, clock and abs (absolute value)
public static final TVScriptNativeFunction CLOCK = new TVScriptNativeFunction(
"clock", //The name of the native function
List.of(), //A list of expected arguments (empty here)
TokenType.TYPE_DECIMAL, //The return type of this function
args -> (double) System.currentTimeMillis() //The native function to be called when the clock function is called in tvscript
);
public static final TVScriptNativeFunction ABS = new TVScriptNativeFunction(
"abs",
List.of(new Parameter("n", TokenType.TYPE_DECIMAL)), //Parameters expected
TokenType.TYPE_DECIMAL,
(Map<String, Object> args) -> {
Object val = args.get("n");
if (val instanceof Integer) return Math.abs((int) val);
if (val instanceof Double) return Math.abs((double) val);
return 0;
}
);
//Configure the global environment with these two functions
Environment globals = new Environment.GlobalBuilder()
.withNativeFunction(NativeFunctions.CLOCK)
.withNativeFunction(NativeFunctions.ABS)
.build();Now to call these functions from withing tvscript, prefix the function name with the native keyword. In the future the usage of this native keyword will be limited to library scripts instead. Right now there is no way to mark a script as a library, so these work globally.
print native abs(n: -10)
print native clock()
Regular function calls must not use native.
Passing data between java and tvscript beyond primitive types will require a native class binding. Native classes are a bit nuanced so let's look at an advanced example to capture these nuances. First a simple Vector2d class with an x and y int field. In java this may look like:
package com.example.tvscript;
public class Vector2d {
public static final Vector2d UNIT_VECTOR = new Vector2d(1, 0);
private int x;
private int y;
public Vector2d(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
public void setX(int x) { this.x = x; }
public void setY(int y) { this.y = y; }
public Vector2d add(Vector2d delta) {
return new Vector2d(x + delta.x, y + delta.y);
}
}We need to bind everything that we want to expose to TVScript, if we don't bind it, it won't be accessible. For the above class a binding would look like this:
public final class NativeClasses {
public static final NativeClass VEC2 = NativeClass.builder("Vec2", Vector2d.class)
// Constructor
.constructor(
params(
param("x", TVType.INTEGER),
param("y", TVType.INTEGER)
),
(args) -> new Vector2d(
(int) args.get("x"),
(int) args.get("y")
)
)
// Properties
.property("x", TVType.INTEGER, Vector2d::getX, Vector2d::setX)
.property("y", TVType.INTEGER, Vector2d::getY, Vector2d::setY)
// Static constant (self reference)
.constant(
"UNIT_VECTOR",
TVType.self(),
Vector2d.UNIT_VECTOR
)
// Method
.method(
"add",
params(param("delta", TVType.self())),
TVType.self(),
(self, args) -> self.add((Vector2d) args.get("delta"))
)
.build();
}Notice that there is a binding for each of the constants, constructors, methods, etc. exposed by this class. The last step on the java side to creating a binding is to add it to the global environment. We do this the same way as with native functions:
Environment globals = new Environment.GlobalBuilder()
.withClass(NativeClasses.VEC2)
.build();Lastly in our script somewhere we need to registrer this class. Since we did all the work on the java side this definition can be as simple as:
native class Vec2:
passYou can also give TVScript specific functionality to these classes
// math/Vec2.tvs
native class Vec2:
function subtract(Vec2 delta) -> Vec2:
return new Vec2(x: x - delta.x, y: y - delta.y)Now let's add to this example. What if we have a native type that uses another native type as a field? We use TVType.ref(...) and let the environment linking phase resolve it after all classes are registered:
public static final NativeClass TRANSFORM = NativeClass.builder("math.Transform", Transform.class)
// Constructor depends on Vec2
.constructor(
params(param("position", TVType.ref(VEC2))),
(args) -> new Transform(
(Vector2d) args.get("position")
)
)
// Property
.property(
"position",
TVType.ref(VEC2),
Transform::getPosition,
Transform::setPosition
)
// Method
.method(
"translate",
params(param("delta", TVType.ref(VEC2))),
TVType.NONE,
(self, args) -> {
self.translate((Vector2d) args.get("delta"));
return null;
}
)
.build();Remember to add it to the global environment and define it in TVScript (with some additional optional functionality):
Environment globals = new Environment.GlobalBuilder()
.withClass(NativeClasses.VEC2)
.withClass(NativeClasses.TRANSFORM)
.build();native class Transform:
function move(Vec2 delta):
translate(delta: delta)
Notice we've used a couple special methods:
TVType.self() → resolved to owning class
TVType.ref(VEC2) → resolved to Vec2
And using them in TVScript feels native to the language and not like a tacked on feature like some other languages feel:
import math.Vec2
import math.Transform
let pos = new Vec2(x: 1, y: 2)
let t = new Transform(position: pos)
t.translate(delta: new Vec2(x: 5, y: 0))
print t.position.x // 6
print t.position.y // 2
let moved = pos.add(new Vec2(x: 1, y: 1))
print moved.x // 2
print moved.y // 3
Native type binding resolves in two phases: definition and linking. All instances of native classes are wrappers around real Java objects.