UniMap: a Practical EsoLang
The ultimate question of life, the universe, and everything is: can it run Doom.
With enough sheer engineering, Doom can run on any platform, whether it’s obscure hardware, an esoteric programming language, or even a living creature. If it can compute, it will run Doom. But have you ever wondered how that’s even possible? Everything is Turing complete.
In basic terms, Turing completeness is the ability of a system to compute any possible algorithm given enough resources. Though it might seem limited to general purpose computers, it has been mathematically and experimentally proven to apply to nearly all basic physical and logical systems, given the right arrangement. All of them are Turing complete. The difference is efficiency.
Humans are curious by nature, but when curiosity takes a break, boredom arrives, and boredom seeks joy, even if that means inventing the maddest creations. Language designers, being human, sought joy in achieving Turing completeness with all sorts of colorful twists, in what’s come to be called esoteric languages.
Esoteric languages come in many kinds and forms, from minimalist to bizarrely styled to powered by crazy architectures, yet they share the same principle of being weird and unconventional, meant to bring a smile to your face. But what if they were practical?
UniMap
Section titled “UniMap”UniMap is a programming language based solely on pattern matching and data structure transformation, stripped of math, control flow, and everything specific to numerical machines. Through composition and mapping, you climb the abstraction layers until you build your own universe, capable of computing your needs based on your own primitives and models.
What makes UniMap unique is its high practicality. Most esolangs focus on achieving Turing completeness in a unique way, with a scaling difficulty that surpasses human ability in most cases. UniMap, on the other hand, focuses on being a regular programming language with a natural developer experience that’s easy to tinker with, requiring only a DIY engineering mindset.
UniMap is inspired by TypeScript’s type system, which still sounds bizarre to this day. It all started when I was tinkering with TypeScript’s type system while creating TEEP (Typed Encoded Expressions Processor). While questioning the point of this powerful Turing completeness, I found modeling a universe in TypeScript to be quite fun, and surprisingly easy.
type bit = 0 | 1;type not<a extends bit> = a extends 0 ? 1 : 0;type and<a extends bit, b extends bit> = [a, b] extends [1, 1] ? 1 : 0;type or<a extends bit, b extends bit> = [a, b] extends [0, 0] ? 0 : 1;type xor<a extends bit, b extends bit> = or<and<a, not<b>>, and<not<a>, b>>;type xor4<a extends bit[4], b extends bit[4]> = [ xor<a[0], b[0]>, xor<a[1], b[1]>, xor<a[2], b[2]>, xor<a[3], b[3]>,];All of TypeScript’s type system power can be traced back to its paradigm: a structural type system with pattern matching through the ternary operator. This discovery led me to port the concept into a real language with a wider feature set and an efficient enough execution engine.
Now that we’ve identified the paradigm, let’s take a quick language tour.
Language Tour
Section titled “Language Tour”UniMap features a symbolic type system, not a numeric one. It has no constructs for numbers or math, unlike the majority of programming languages. Its data structures are immutable, heterogeneous, and deeply nestable.
// symbols are primitive values that are globally unique and identified by their name// they must be declared to be usesymbol a, b, c, d, e;
// the language predeclare numbers as symbols.
// records are heterogeneous key-value data structures that have symbols as fieldslet obj = { a = 1, b = c };let obj2 = { c = { d = e }, ..obj };
// arrays are heterogeneous lists of values.let arr = [1, 2, 3];let arr2 = [a, obj, ..arr];
// they are accessed by dot and index notationlet field_a = obj2.a; // => 1let field_b = arr2[field_a].b; // => cNothing is mutated. You take data structures, match them against a set of patterns, then map them into new structures. Data transformation is the flow, the logic, and the state.
// the map expression `expr:{}` is the heart of unimap// it is composed of multiple arms `pattern => map_expr` matched in orderlet map_to_nb = b: { a => 1, b => 2, // match any thing _ => 3,}; // => 2
// `let` pattern match any thing and store in variablelet map_a = c: { a => 1, let x => x,}; // => c
// map support structural matchinglet map_obj = obj: { { a: 1, let b } => b, { c: { d: _ } } => 1,}; // => c
let map_arr = arr: { [1, _, 4] => 1, [1, ..let rest] => rest,}; // => [2, 3]
// map power stems in its ability to compose and nestlet map_power = obj2: { { let c } => c: { { d: a | c | e } => 1, }, [_, { a: obj },..] => 2}; // => 1The pipe operator is the most underrated operator in programming, and the most powerful, after match. it’s the structural backbone of every UniMap program.
// the result of an expression is passed to the next expression in the chain through `_`let pipe1 = 1 |> [_, 2, 3] |> [.._, 4, _[1]]; // => [1, 2, 3, 4, 2]
// compined with the map operator, expressivity composelet piped = 1 |> [_, 2, 3] |> _: { [1, ..let rest] => { a: rest }, [2, ..let rest] => { b: rest },} |> _.a; // => [2, 3]
// forget purity, i want to debug my code// `dbg` is a builtin function that prints its argumentlet debugged = [1, 2, 3] |> dbg(_[1]) |> [..rest, 4]; // print 2The map and pipe combo conquers the nano level by expressing all statements and control flow constructs, though languages still need something capable of composing at the program level, and that’s what functions are for.
// functions take single expression bodies.fn map_value(val, from, to) => val: { from => to, let x => x,};let mapped = map_value(1, 1, 2); // => 2let not_mapped = map_value(3, 1, 2); // => 3
// the can be called recursively, but can not be passed as valuesfn filter(arr, to_filter) => arr: { [] => [], [to_filter, ..let rest] => filter(rest, to_filter), [let x, ..let rest] => [x, ..filter(rest, to_filter)],};let filtered = filter([1, 2, 3], 2); // => [1, 3]
// the main function to runfn main () => obj.a: { 1 => 1 |> [_, 2, 3] |> filter(_, 2), 2 => map_value(1, 1, 2) |> dbg(_), 3 => panic()};
// endless recursion till stack overflowsfn panic () => panic();UniMap doesn’t stop here. It also features quality-of-life additions like a module system, symbol enums, and continuation mode. For more details, read the language reference.
Now that we’ve had a quick immersion in the syntax, let’s talk about UniMap’s purpose.
A Single Purpose
Section titled “A Single Purpose”A Practical Language
Section titled “A Practical Language”From what you’ve seen, UniMap is a practical language like any regular language in terms of semantic power. It can accomplish any task an ordinary language can, with a natural coding experience and typical syntactic convenience. It’s expressive and composable like any ordinary expression-based language.
Most esolangs you find in the wild are modeled around basic numerical machines: an instruction executor with stack or cell-based memory, with the twist coming in instructions encoding: character chaos like BrainF*ck and its derivatives, text interpreters like Chef, and non-text mediums like Piet. Sometimes the processor itself is the crazy part, as in Malbolge.
They’re modeled like numerical processors out of tradition and ease of implementation. The goal is simply to be great fun, and they are. But even numerical processors, CISC or theoretical alike, don’t come close to the expressivity of expression-based languages, since they simply exist on different abstraction layers.
A numerical processor’s identity is in its efficiency, while a language’s identity is in its convenience. The things that make typical programming languages unique, expression tree syntax, fluid data structure flow, syntactic freedom, and named constructs, are off-limits to numerical processors, since processors need to be fast and compact.
UniMap, being a normal programming language harnesses all the uniqueness of typical languages. While few esolangs are regular languages, they still follow the tradition of having weird syntax. UniMap, on the other hand, behaves like a good programming language and includes unrequired quality-of-life features like a cycle-friendly module system. UniMap is an esolang simply because of its unconventional paradigm: matching and mapping.
An Engineering Puzzle
Section titled “An Engineering Puzzle”UniMap is highly minimal, providing a bare foundation, yet convenient enough to remove annoying semantic restrictions. Its purpose is to be a great puzzle for engineers in their free time, a puzzle where you build your own universe with your own hand-built primitives and structures to compute your specific needs, using highly expressive tools that are easy to enjoy.
While UniMap is expressive, it’s still so minimal that you have to reinvent math yourself, from arithmetic to logical operators to even number notation. Don’t worry though, because as with anything in programming, once you have your utilities in place, everything becomes familiar, with the only difference being functions instead of binary operators.
Function composition is the expression of complexity, not only in UniMap but in the whole universe. You also have map and pipe, the ultimate combo in the expression landscape. Add to that an open-ended data structure flow and the ever-reliable lookup map. You, dear engineer, have a very powerful minimal toolset with capabilities surpassing many ordinary programming languages.
UniMap exists to give everyone the joy of system design. Its expressivity isn’t there for show, it’s meant to establish a minimal yet familiar medium for anyone to express their system design abilities, free from unnecessary restrictions. The only requirement is the mindset of an engineer stranded on an abandoned planet.
Examples
Section titled “Examples”Now that we’ve discussed the language and its purpose, let’s look at some examples to further understand its power, starting with hello world.
// Braille pattern dots-0 as whitespacesymbol hello⠀world!;fn main () => hello⠀world!;Now for a real and interesting example, which you can find here.
Conway’s Game of Life
Section titled “Conway’s Game of Life”Conway’s Game of Life is a famous cellular automaton, for those who don’t know it. It’s a grid of cells that can be dead or alive, and on each turn every cell updates based on the state of its adjacent cells. Here we’ll implement it using the base rules.
Let’s start with the grid representation. There are countless possible configurations, but here it’ll be a 16x16 array of 0s and 1s, a classic choice, with a size of 16 since it’s a satisfying 2^2^2. The initial grid will be a checkerboard.
let grid_size = 16;let init_grid = [ [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1], [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], /* other 14 rows */ [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1], [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0],];Now for addition, the only required math operation. It’ll be a + b, where a is 0-15 (grid position) and b is 0 or 1 (cell state), or -1/1 (neighbor offsets). Addition, like most math primitives here, is implemented as a lookup map, UniMap’s version of a lookup table.
symbol -1;fn add(a, b) => b: { -1 => a: { 0 => -1, 1 => 0, 2 => 1, 3 => 2, 4 => 3, 5 => 4, 6 => 5, 7 => 6, 8 => 7, 9 => 8, 10 => 9, 11 => 10, 12 => 11, 13 => 12, 14 => 13, 15 => 14, }, 0 => a, 1 => a: { 0 => 1, 1 => 2, 2 => 3, 3 => 4, 4 => 5, 5 => 6, 6 => 7, 7 => 8, 8 => 9, 9 => 10, 10 => 11, 11 => 12, 12 => 13, 13 => 14, 14 => 15, 15 => 16, },};After that we can move on to the Game of Life mechanics, starting with the get_cell function, which reads the status of a cell from its coordinates while handling border neighbors, using a nested map.
fn get_cell(grid, x, y) => y: { -1 => 0, // top border grid_size => 0, // bottom border _ => x: { -1 => 0, // left border grid_size => 0, // right border _ => grid[y][x], },};Second, and most importantly, update_cell, which, true to its name, updates a cell’s status. Given its coordinates, it gathers its neighbors’ states in an unrolled loop via an accumulation pipe chain, then determines the new status based on the rules using another nested map.
fn update_cell(grid, x, y) => get_cell(grid, add(x, -1), add(y, -1)) |> add(_, get_cell(grid, x, add(y, -1))) |> add(_, get_cell(grid, add(x, 1), add(y, -1))) |> add(_, get_cell(grid, add(x, -1), y)) |> add(_, get_cell(grid, add(x, 1), y)) |> add(_, get_cell(grid, add(x, -1), add(y, 1))) |> add(_, get_cell(grid, x, add(y, 1))) |> add(_, get_cell(grid, add(x, 1), add(y, 1))) |> grid[y][x]: { // dead + 3 alive => live 0 => _: { 3 => 1, _ => 0 }, // alive + 2 or 3 alive => thrive 1 => _: { 2 | 3 => 1, _ => 0 }, };From a single cell, we then scale up to the whole grid using two recursive index-counter loops: one over rows, one over each row’s cells.
fn update_row(grid, x, y) => x: { grid_size => [], _ => [update_cell(grid, x, y), ..update_row(grid, add(x, 1), y)],};
fn update_grid(grid, y) => y: { grid_size => [], _ => [update_row(grid, 0, y), ..update_grid(grid, add(y, 1))],};With the grid updated once, we could use a recursive loop for the main loop, but UniMap offers an optimization for deep main loops called continuation mode. In continuation mode, the runtime calls init to produce the initial state, then calls loop each turn to produce the next state, until an end symbol is returned.
The loop will run for 100 turns. Naively extending add to cover 100 items would work, but it’s verbose. A better solution is to recall the elementary-school lesson on multi-digit addition and implement an inc function dedicated to incrementing multi-digit numbers.
// numbers are little endian hex digit arrays, math low little endianfn inc(nb) => nb: { [] => [], // overflow // 15 0 1 0 -> 1 1 1 0 [15, ..let rest] => [0, ..inc(rest)], // 1 0 1 0 -> 2 0 1 0 [let dg, ..let rest] => [add(dg, 1), ..rest],};
symbol continue, end, gen, grid;fn init() => { generation = [0, 0, 0, 0], grid = init_grid };fn loop (state) => state.generation: { // 100 = 0x64 [4, 6, 0, 0] => [end, state.grid], _ => [continue, { generation = inc(state.generation), grid = update_grid(state.grid, 0) }],};As you can see, we’ve implemented Conway’s Game of Life in a straightforward and concise way. The language didn’t restrict us, it empowered us, and most importantly, it was a great puzzle. if Game of Life proved UniMap can simulate, let’s see if it can compute
Pi Calculator
Section titled “Pi Calculator”If you wish to make a pie, you must first invent the universe, and in UniMap, we’re famous for building universes. So let’s calculate π using the Nilakantha series.
Calculating π is a great challenge, since it’s math-heavy, and the Nilakantha series is no exception. It’s a simple series that converges to π at a very acceptable rate while being incredibly straightforward: 3 + 4/(2·3·4) - 4/(4·5·6) + ... + 4·(-1)ⁿ / (2n · (2n+1) · (2n+2)).
Seeing the formula, you might be worried about division, and honestly, you should be. Though, using fractions, we can eliminate it entirely, leaving only intermediate-level multiplication. Division is technically needed to keep fraction growth under control via GCD, but that would double the size of this example, so we’ll keep it simple.
Starting again with addition, borrow the version you already built, but extend it to full-range input with carry support. It’ll grow roughly 32-fold in scope, so it’s recommended to move it to its own file. Then evolve it from a single-digit adder into a ripple-carry adder using a plain recursive loop.
fn add_dg (a, b, c) => a: { 0 => b: { 0 => c: { 0 => [0, 0], 1 => [1, 0], }, 1 => c: { 0 => [1, 0], 1 => [2, 0], }, // ... 15 => c: { 0 => [15, 0], 1 => [0, 1], }, }, // ... 15 => b: { /* .. */ },};
fn add_op(a, b, c) => [a, b]: { [[], []] => [], [[let a_first, ..let a_rest], [let b_first, ..let b_rest]] => add_dg(a_first, b_first, c) |> [_[0], ..add_op(a_rest, b_rest, _[1])],};fn add(a, b) => add_op(a, b, 0);After that comes multiplication. Binary multiplication is elegant: just a series of test-add-shifts across the whole bit range. Like addition, we split it into digit-level and whole-array-level logic. The only real requirement is a few clever bit tricks.
// get the nth bit of a hex digitfn bit(dg, n) => n: { 0 => dg: { 0 => 0, 1 => 1, 2 => 0, 3 => 1, 4 => 0, 5 => 1, 6 => 0, 7 => 1, 8 => 0, 9 => 1, 10 => 0, 11 => 1, 12 => 0, 13 => 1, 14 => 0, 15 => 1 }, 1 => dg: { 0 => 0, 1 => 0, 2 => 1, 3 => 1, 4 => 0, 5 => 0, 6 => 1, 7 => 1, 8 => 0, 9 => 0, 10 => 1, 11 => 1, 12 => 0, 13 => 0, 14 => 1, 15 => 1 }, 2 => dg: { 0 => 0, 1 => 0, 2 => 0, 3 => 0, 4 => 1, 5 => 1, 6 => 1, 7 => 1, 8 => 0, 9 => 0, 10 => 0, 11 => 0, 12 => 1, 13 => 1, 14 => 1, 15 => 1 }, 3 => dg: { 0 => 0, 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0, 6 => 0, 7 => 0, 8 => 1, 9 => 1, 10 => 1, 11 => 1, 12 => 1, 13 => 1, 14 => 1, 15 => 1 }};
// mutliply dg by dgfn mult_op(a, b, prod) => b: { [] => [], [let b_first, ..let b_rest] => // test add shift chain over 4 bits [[..prod, 0], [..a, 0]] // increase precision // a + a == 2a == a << 1 |> [bit(b_first, 0): { 0 => _[0], 1 => add(_[0], _[1]) }, add(_[1], _[1])] |> [bit(b_first, 1): { 0 => _[0], 1 => add(_[0], _[1]) }, add(_[1], _[1])] |> [bit(b_first, 2): { 0 => _[0], 1 => add(_[0], _[1]) }, add(_[1], _[1])] |> bit(b_first, 3): { 0 => _[0], 1 => add(_[0], _[1]) }: { // free 4 bit shift [let p_first, ..let p_rest] => [p_first, ..mult_op(a, b_rest, p_rest)], }};fn mult(a, b) => mult_op(a, b, zero);Now, fraction addition, according to the formula you learned in school.
symbol num, den;fn fadd (a, b) => { num = add(mult(a.num, b.den), mult(a.den, b.num)), den = mult(a.den, b.den),};The math operators we’ve built are independent of digit-array size, so let’s use 256 bit words. That sounds large, but it can only compute about 20 terms before overflowing, since we skipped GCD reduction. Now let’s define some constants.
// 256 bit number: starts with 1 a and remaining is bfn a_63b (a, b) => [ a, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b,];let zero = a_63b(0, 0);let one = a_63b(1, 0);let two = a_63b(2, 0);let three = a_63b(3, 0);let four = a_63b(4, 0);let -four = a_63b(12, 15); // -4 = fff..ffc, this remove the need of subtractionNow we can implement the Nilakantha series using continuation mode. Yes, it really is this straightforward, thanks to the ergonomics of function composition.
fn term (n) => { // 4 * (-1) ^ (n/2) // 4n = -4, 4n+2 = +4 num = bit(n[0], 1): { 1 => four, 0 => -four }, // n * (n+1) * (n+2) den = mult(n, add(n, one)) |> mult(_, add(n, two)),};
symbol continue, end, n, pi;fn init() => { n = two, pi = { num = three, den = one }};fn loop (state) => state.n: { // only 19 turns since it will overflow, gcd is required [8, 2, ..] => [end, state.pi], _ => [continue, { n = add(state.n, two), pi = fadd(state.pi, term(state.n)) }],};The Math is very harsh to implement, you have to reinvint numbers from principles, though you don’t have to reinvent your universe’s math from scratch on every project. You can borrow it from your older work, building a math standard library, or even borrow from someone else’s. Just as functions compose, standard modules can compose too.
Now, after these examples, let’s finish with some light insights.
Insights
Section titled “Insights”Another Chaotic Contraption
Section titled “Another Chaotic Contraption”Every bold thesis needs a serious contraption backing it up, and a Doom port would have been the obvious choice, hilarious yet technically significant. But I didn’t choose it. Porting Doom as software is relatively easy, it’s porting a masterclass in modularity, and a C compiler or a WASM runtime would do the job.
The ambitious part is integrating Doom into an esoteric environment. That’s the engineer’s obsession, and what most people overlook. It requires building a virtual machine anyway, so I figured, let’s just make it original.
MEP (Mapped Expressions Processor) is an advanced 64-bit CPU implemented in UniMap. It was the first real thing I built with the language, and its stress test. Why a CPU, of all things? I had schematics lying around from TEEP’s creation, I’m a logic designer by nature, and pattern matching was practically made for transformers and executors.
Even though MEP has a fairly full-featured ISA, its implementation is tiny, even relative to mainstream programming languages. Dive into the source and you’ll be surprised how simple it is. The reason is that it’s just a math function dispatcher built on lookup maps and branching maps, with a few unrolled loops and block data movers mixed in, all native UniMap operations.
Note that this description also applies to TEEP and TypeScript, but that’s a story for another time.
Language Paradigm
Section titled “Language Paradigm”UniMap has an unusual relationship with language paradigms. Beyond its primary paradigm of matching and mapping, and data transformation, it’s fully declarative and never imperative. You define mappings, you don’t mutate state. It’s not functional either, since functions aren’t values, and it has no native object-based dispatch to call it object-oriented.
That said, it’s heavily inspired by functional languages in many of its core features: pattern matching, the pipe operator, expression-oriented syntax, immutable data structures, full purity, and the algorithmic style of data transformation and recursion. It also shows some love for data structures in a way closer to OOP, since object composition and modeling are fundamental to UniMap.
UniMap could have shipped without the pipe operator, without records, without dot/index notation, leaning into ternary matching alone, and without any of the quality-of-life features it now has. It chose not to, since it doesn’t seek to be just another pure, minimal language. It seeks a well-designed minimalism, minimal in paradigm, not in style or computational model.
Shared Uniqueness
Section titled “Shared Uniqueness”Data transformation isn’t just a clean-code nicety, it’s the thing every serious engine quietly runs on: compilers, parsers, and dataflow. Mutation can’t touch it here, since transformation is simply the natural shape of the problem. It’s functional programming’s whole pitch, showing up in plenty of constructs, but the loudest and most notable of them is pattern matching, and UniMap is far from alone in building around it.
Many programming languages and nearly all modern ones feature pattern matching, though few build their identity around it. Notable exceptions are term-rewriting languages (Maude, Stratego/XT) and logic languages (Prolog, Datalog), where you define rules and the runtime pattern-matches them against input data, indefinitely, until it reaches a solution.
UniMap’s uniqueness is being a middle ground. Pattern matching is its identity and foundation, but it remains an explicit operation inside regular program flow, the way it appears in most conventional languages. That framing isn’t UniMap’s invention either, it’s borrowed from TypeScript’s type system, and UniMap just refined it.
Surprisingly, this same concept shows up in Scala’s type system, with its match types and structural typing, and in Flow, though Flow is nearly identical to TypeScript. UniMap’s paradigm tends to emerge in type systems the moment they acquire generics, structural typing, and pattern matching. So please, keep your type systems primitive, and find other forms of expressivity. We don’t need another Doom in a type system.
That’s UniMap, a unique programming language that merges powerful features with an unconventional, minimalist paradigm, creating a convenient kind of difficulty that drives genuinely unrestricted design. Just a great puzzle for engineers, and anyone drawn to system design.
Through sheer mapping, you build your own universe with your bare hands exactly to your own needs. So let the ideas thrive in this convenient environment.