Skip to content

Language Guide

Dimitri edited this page Aug 29, 2026 · 3 revisions

This page covers PotScript's syntax and core semantics: lexical structure, values and types, variables, operators, control flow and functions.

Lexical structure

Comments

# everything after a hash, to the end of the line

There are no block comments.

Statement terminators

A statement ends at a newline or a semicolon (;). Newlines are suppressed inside parentheses and brackets, so calls and list literals may span lines:

let grid = [
    1, 2, 3,
    4, 5, 6
]
print("a",
      "b")

Braces do not suppress newlines — a { } block is a sequence of statements, one per line.

let a = 1; let b = 2      # semicolons work too

Identifiers

Letters (az, AZ), _, and digits after the first character. Case sensitive.

Keywords

let  fn  if  else  while  for  in  return  break  continue
and  or  not  true  false  nil

Builtin function names are not reserved — let print = 3 is legal and shadows the builtin for the rest of the program.

Numbers

Decimal only, always 64-bit floating point: 42, 3.14, 0.5. There is no hex, no exponent notation, and no leading-dot form (.5 is invalid — write 0.5). A trailing dot is not part of the number (5. lexes as 5 followed by an error).

Strings

Double quotes only. Escapes: \n, \t, \", \\. Any other escape is a compile error, and a raw newline inside a string is an "unterminated string" error.

print("tab\there\nnew line")

Operators and punctuation

+  -  *  /  %        ( )  [ ]  { }  ,
=  ==  !=  <  <=  >  >=

A bare ! is a compile error: use not for negation and != for inequality.


Values and types

Six types, reported by type(x):

Type name Values
nil nil
bool true, false
number double-precision float
string UTF-16 text
list mutable, heterogeneous, 0-indexed
function user functions and builtins

Truthiness

Only nil and false are falsy. 0 and "" are truthy.

if 0 { print("this prints") }

Number printing

Whole numbers below 1e15 print without a decimal point; everything else prints as a Java double.

print(10 / 4)     # 2.5
print(10 / 5)     # 2

Equality

== and != compare by value. Lists compare element-by-element (deeply). Types never coerce: 1 == "1" is false.

print([1, [2]] == [1, [2]])   # true

Lists

Lists are reference values: assigning one to another variable or passing one to a function does not copy it.

let a = [1, 2]
let b = a
push(b, 3)
print(a)          # [1, 2, 3]

The only exception is send/broadcast, which deep-copy the payload so sender and receiver never share mutable state.


Variables

let declares. At the top level it creates a global; inside any { } block it creates a local.

let count = 0        # global
let empty            # declared, initialised to nil
{
    let count = 99   # a distinct local, shadows the global
    print(count)     # 99
}
print(count)         # 0

Rules:

  • Assigning to a name that was never declared is a runtime error (undefined variable 'x') — always let first.
  • Reading an undeclared name is the same error.
  • Declaring the same local twice in one scope is a compile error. Re-leting a global is allowed and simply overwrites it.
  • Locals are limited to 256 per function.

Assignment is an expression that yields the assigned value, so (x = 5) and print(x = 5) both work — though the plain statement form is what you normally want.


Operators

Arithmetic

Operator Operands Notes
+ number+number addition
+ anything with a string concatenation — the other side is stringified
+ list+list returns a new joined list
- * / % numbers only / and % by zero are runtime errors
-x number negation
print("count: " + 3)    # "count: 3"
print([1] + [2])        # [1, 2]
print(7 % 3)            # 1
print(-7 % 3)           # -1   (sign follows the left operand)

Adding two values that are neither of the above (e.g. a bool and a number) is a runtime error.

Comparison

<, <=, >, >= accept numbers only — comparing strings or bools raises expected a number, got .... There is no lexicographic string comparison.

Comparisons do not chain usefully: 1 < 2 < 3 parses as (1 < 2) < 3 and then fails at runtime because true is not a number.

Logic

and, or short-circuit and return one of their operands, not a coerced bool:

let name = load("name") or "unnamed"    # nil-coalescing idiom
let ok = has_msg() and recv()           # only receives if a message is waiting

not x always returns a bool.

Indexing

x[i] reads from a list or a string; x[i] = v writes into a list only.

  • Indices must be numbers; fractional indices are floored.
  • Negative indices count from the end: l[-1] is the last element.
  • Out-of-range indices are runtime errors — there is no silent nil.
  • Indexing a string yields a one-character string.
let word = "pot"
print(word[0])      # "p"
print(word[-1])     # "t"

Precedence

Lowest to highest:

=                       (assignment)
or
and
==  !=
<  <=  >  >=
+  -
*  /  %
-x  not x               (unary)
f(...)   x[...]         (call, index)

All binary operators are left-associative. Parentheses group as usual.


Control flow

if / else

if light() > 10 {
    print("day")
} else if light() > 4 {
    print("dusk")
} else {
    print("night")
}

Braces are mandatory — there are no brace-less bodies. The else may sit on its own line after the closing brace.

while

let i = 0
while i < 10 {
    if i == 3 { i = i + 1; continue }
    if i == 8 { break }
    print(i)
    i = i + 1
}

break and continue are compile errors outside a loop; both correctly discard the locals of the scopes they exit.

for

for x in [10, 20, 30] {
    print(x)
}

for i in range(5) {        # 0 1 2 3 4
    print(i)
}

for ch in "abc" {          # "a" "b" "c"
    print(ch)
}

for iterates the elements of a list, or the characters of a string, front to back. The loop variable is a fresh local scoped to the body; it does not leak, and assigning to it does not affect the collection. break and continue work exactly as in while.

The loop walks the list itself, not a copy: the length is re-checked every pass, so a body that pushes or pops the list it is iterating changes how far the loop runs. Iterating anything that is not a list or string is a runtime error.

Blocks

A bare { } is a statement and introduces a scope:

{
    let temp = expensive()
    print(temp)
}   # temp is gone here

Functions

fn add(a, b) {
    return a + b
}

fn fib(n) {
    if n < 2 { return n }
    return fib(n - 1) + fib(n - 2)
}

print(add(2, 3))    # 5

Rules and limits:

  • Top level only. Declaring fn inside a block, a loop or another function is a compile error (functions must be declared at top level).

  • No closures. A function body sees its own parameters and locals, plus globals. It cannot capture a local from an enclosing scope.

  • Arity is exact. Calling with the wrong number of arguments is a runtime error. Maximum 16 parameters and 16 arguments.

  • A function that falls off the end, or uses bare return, returns nil.

  • Recursion works, up to 64 call frames deep (call stack overflow past that).

  • Functions are ordinary values — store them in variables and lists and call them later:

    let ops = [add, fib]
    print(ops[0](1, 2))
    
  • A fn declaration takes effect when execution reaches it. Since top-level code runs top to bottom, mutual recursion is fine as long as both functions are declared before the first call executes.


See also: Execution Model · Standard Library · Wiki Home