3707 stories
·
3 followers

Relatively New Things You Should Know about HTML Heading Into 2025

1 Share

Comments

Read the whole story
emrox
14 hours ago
reply
Hamburg, Germany
Share this story
Delete

New Front-End Features For Designers In 2025

1 Comment and 2 Shares

Component-specific styling, styling parents based on their children, relative colors — the web platform is going through exciting times, and many things that required JavaScript in the past can today be achieved with one simple line of HTML and CSS.

As we are moving towards 2025, it’s a good time to revisit some of the incredible new technologies that are broadly available and supported in modern browsers today. Let’s dive right in and explore how they can simplify your day-to-day work and help you build modern UI components.

Table of Contents

Below you’ll find quick jumps to topics you may be interested in, or skip the table of contents.

CSS Container Queries And Style Queries

Component-specific styling? What has long sounded like a dream to any developer, is slowly but surely becoming reality. Thanks to container queries, we can now query the width and style of the container in which components live.

As Una Kravets points out in her introduction to style queries, this currently only works with CSS custom property values, but there are already some real-world use cases where style queries shine: They come in particularly handy when you have a reusable component with multiple variations or when you don’t have control over all of your styles but need to apply changes in certain cases.

If you want to dive deeper into what’s possible with container style queries and the things we can — maybe — look forward to in the future, also be sure to take a look at Geoff Graham’s post. He dug deep into the more nuanced aspects of style queries and summarized the things that stood out to him.

No More Typographic Orphans And Widows

We all know those headlines where the last word breaks onto a new line and stands there alone, breaking the visual and looking, well, odd. Of course, there’s the good ol’ <br> to break the text manually or a <span> to divide the content into different parts. But have you heard of text-wrap: balance already?

By applying the text-wrap: balance property, the browser will automatically calculate the number of words and divide them equally between two lines — perfect for page titles, card titles, tooltips, modals, and FAQs, for example. Ahmad Shadeed wrote a helpful guide to text-wrap: balance in which he takes a detailed look at the property and how it can help you make your headlines look more consistent.

When dealing with large blocks of text, such as paragraphs, you might want to look into text-wrap: pretty to prevent orphans on the last line.

Auto Field-Sizing For Forms

Finding just the right size for an input field usually involves a lot of guesswork — or JavaScript — to count characters and increase the field’s height or width as a user enters text. CSS field-sizing is here to change that. With field-sizing, we can auto-grow inputs and text areas, but also auto-shrink short select menus, so the form always fits content size perfectly. All we need to make it happen is one line of CSS.

Adam Argyle summarized everything you need to know about field-sizing, exploring in detail how field-sizing affects different <form> elements. To prevent your input fields from becoming too small or too large, it is also a good idea to insert some additional styles that keep them in shape. Adam shares a code snippet that you can copy-and-paste right away.

Making Hidden Content Searchable

Accordions are a popular UI pattern, but they come with a caveat: The content inside the collapsed sections is impossible to search with find-in-page search. By using the hidden=until-found attribute and the beforematch event, we can solve the problem and even make the content accessible to search engines.

As Joey Arhar explains in his guide to making collapsed content searchable, you can replace the styles that hide the section with the hidden=until-found attribute. If your page also has another state that needs to be kept in sync with whether or not your section is revealed, he recommends adding a beforematch event listener. It will be fired on the hidden=until-found element right before the element is revealed by the browser.

Styling Groups Within Select Menus

It’s a small upgrade for the <select> element, but a mighty one: We can now add <hr> into the list of select options, and they will appear as separators to help visually break up the options in the list.

If you want to refine things further, also be sure to take a look at <optgroup>. The HTML element lets you group options within a <select> element by adding a subheading for each group.

Simpler Snapping For Scrollable Containers

Sometimes, you need a quick and easy way to make an element a scrollable container. CSS scroll snap makes it possible. The CSS feature enables us to create a well-controlled scrolling experience that lets users precisely swipe left and right and snap to a specific item in the container. No JavaScript required.

Ahmad Shadeed wrote a practical guide that walks you step by step through the process of setting up a container with scroll snap. You can use it to create image galleries, avatar lists, or other components where you want a user to scroll and snap through the content, whether it’s horizontally or vertically.

Anchor Positioning For Tooltips And Popovers

Whether you use it for footnotes, tooltips, connector lines, visual cross-referencing, or dynamic labels in charts, the CSS Anchor Positioning API enables us to natively position elements relative to other elements, known as anchors.

In her introduction to the CSS Anchor Positioning API, Una Kravets summarized in detail how anchor positioning works. She takes a closer look at the mechanism behind anchor positioning, how to tether to one and multiple anchors, and how to size and position an anchor-positioned element based on the size of its anchor. Browser support is still limited, so you might want to use the API with some precautions. Una’s guide includes what to watch out for.

High-Definition Colors With OKLCH And OKLAB

With high-definition colors with LCH, okLCH, LAB, and okLAB that give us access to 50% more colors, the times of RGB/HSL might be over soon. To get you familiar with the new color spaces, Vitaly wrote a quick overview of what you need to know.

Both OKLCH and OKLAB are based on human perception and can specify any color the human eye can see. While OKLAB works best for rich gradients, OKLCH is a fantastic fit for color palettes in design systems. OKLCH/OKLAB colors are fully supported in Chrome, Edge, Safari, Firefox, and Opera. Figma doesn’t support them yet.

Relative Colors In CSS

Let’s say you have a background color and want to reduce its luminosity by 25%, or you want to use a complementary color without having to calculate it yourself. The relative color syntax (RCS) makes it possible to create a new color based on a given color.

To derive and compute a new color, we can use the from keyword for color functions (color(), hsl(), oklch(), etc.) to modify the values of the input color. Adam Argyle shares some code snippets of what this looks like in practice, or check the spec for more details.

Smooth Transitions With The View Transitions API

There are a number of use cases where a smooth visual transition can make the user experience more engaging. When a thumbnail image on a product listing page transitions into a full-size image on the product detail page, for example, or when you have a fixed navigation bar that stays in place as you navigate from one page to another. The View Transitions API helps us create seamless visual transitions between different views on a site.

View transitions can be triggered not only on a single document but also between two different documents. Both rely on the same principle: The browser takes snapshots of the old and new states, the DOM gets updated while rendering is suppressed, and the transitions are powered by CSS Animations. The only difference lies in how you trigger them, as Bramus Van Damme explains in his guide to the View Transitions API. A good alternative to single page apps that often rely on heavy JavaScript frameworks.

Exclusive Accordions

The ‘exclusive accordion’ is a variation of the accordion component. It only allows one disclosure widget to be open at the same time, so when a user opens a new one, the one that is already open will be closed automatically to save space. Thanks to CSS, we can now create the effect without a single line of JavaScript.

To build an exclusive accordion, we need to add a name attribute to the <details> elements. When this attribute is used, all <details> elements that have the same name value form a semantic group and behave as an exclusive accordion. Bramus Van Damme summarized in detail how it works.

Live And Late Validation

When we use :valid and :invalid to apply styling based on a user’s input, there’s a downside: a form control that is required and empty will match :invalid even if a user hasn’t started interacting with it yet. To prevent this from happening, we usually had to write stateful code that keeps track of input a user has changed. But not anymore.

With :user-valid and :user-invalid, we now have a native CSS solution that handles all of this automatically. Contrary to :valid and :invalid, the :user-valid and :user-invalid pseudo-classes give users feedback about mistakes only after they have changed the input. :user-valid and :user-invalid work with input, select, and textarea controls.

Smooth Scrolling Behavior

Imagine you have a scrolling box and a series of links that target an anchored position inside the box. When a user clicks on one of the links, it will take them to the content section inside the scrolling box — with a rather abrupt jump. The scroll-behavior property makes the scrolling transition a lot smoother, only with CSS.

When setting the scroll-behavior value to smooth, the scrolling box will scroll in a smooth fashion using a user-agent-defined easing function over a user-agent-defined period of time. Of course, you can also use scroll-behavior: auto, and the scrolling box will scroll instantly.

Making Focus Visible

Focus styles are essential to help keyboard users navigate a page. However, for mouse users, it can be irritating when a focus ring appears around a button or link as they click on it. :focus-visible is here to help us create the best experience for both user groups: It displays focus styles for keyboard users and hides them for mouse users.

:focus-visible applies while an element matches the :focus pseudo-class and the User Agent determines via heuristics that the focus should be made visible on the element. Curious how it works in practice? MDN Web Docs highlights the differences between :focus and :focus-visible, what you need to consider accessibility-wise, and how to provide a fallback for old browser versions that don’t support :focus-visible.

Styling Parents Based On Children

Historically, CSS selectors have worked in a top-down fashion, allowing us to style a child based on its parent. The new CSS pseudo-class :has works the other way round: We can now style a parent based on its children. But that’s not all yet. Josh W. Comeau wrote a fantastic introduction to :has in which he explores real-world use cases that show what the pseudo-class is capable of.

:has is not limited to parent-child relationships or direct siblings. Instead, it lets us style one element based on the properties or status of any other element in a totally different container. And it can be used as a sort of global event listener, as Josh shows — to disable scrolling on a page when a modal is open or to create a JavaScript-free dark mode toggle, for example.

Interpolate Between Values For Type And Spacing

CSS comparison functions min(), max(), and clamp() are today supported in all major browsers, providing us with an effective way to create dynamic layouts with fluid type scales, grids, and spacing systems.

To get you fit for using the functions in your projects right away, Ahmad Shadeed wrote a comprehensive guide in which he explains everything you need to know about min(), max(), and clamp(), with practical examples and use cases and including all the points of confusion you might encounter.

If you’re looking for a quick and easy way to create fluid scales, the Fluid Type Scale Calculator by Utopia has got your back. All you need to do is define min and max viewport widths and the number of scale steps, and the calculator provides you with a responsive preview of the scale and the CSS code snippet.

Reliable Dialog And Popover

If you’re looking for a quick way to create a modal or popup, the <dialog> HTML element finally offers a native (and accessible!) solution to help you get the job done. It represents a modal or non-modal dialog box or other interactive component, such as a confirmation prompt or a subwindow used to enter data.

While modal dialog boxes interrupt interaction with a page, non-modal dialog boxes allow interaction with the page while the dialog is open. Adam Argyle published some code snippets that show how <dialog> can block pop-ups and popovers for non-blocking menus, out of the box.

Responsive HTML Video And Audio

In 2014, media attribute support for HTML video sources was deleted from the HTML standard. Last year, it made a comeback, which means that we can use media queries for delivering responsive HTML videos.

Scott Jehl summarized how responsive HTML video — and even audio — works, what you need to consider when writing the markup, and what other types of media queries can be used in combination with HTML video.

The Right Virtual Keyboard On Mobile

It’s a small detail, but one that adds to a well-considered user experience: displaying the most comfortable touchscreen keyboard to help a user enter their information without having to switch back and forth to insert numbers, punctuation, or special characters like an @ symbol.

To show the right keyboard layout, we can use inputmode. It instructs the browser which keyboard to display and supports values for numeric, telephone, decimal, email, URL, and search keyboards. To further improve the UX, we can add the enterkeyhint attribute: it adjusts the text on the Enter key. If no enterkeyhint is used, the user agent might use contextual information from the inputmode attribute.

A Look Into The Future

As we are starting to adopt all of these shiny new front-end features in our projects, the web platform is, of course, constantly evolving — and there are some exciting things on the horizon already! For example, we are very close to getting masonry layout, fully customizable drop-downs with <selectmenu>, and text-box trimming for adjusting fonts to be perfectly aligned within the grid. Kudos to all the wonderful people who are working tirelessly to push the web forward! 👏

In the meantime, we hope you found something helpful in this post that you can apply to your product or application right away. Happy tinkering!

Smashing Weekly Newsletter

You want to stay on top of what’s happening in the world of front-end and UX? With our weekly newsletter, we aim to bring you useful, practical tidbits and share some of the helpful things that folks are working on in the web industry. Every issue is curated, written, and edited with love and care. No third-party mailings or hidden advertising.

Also, when you subscribe, you really help us pay the bills. Thank you for your kind support!



Read the whole story
emrox
19 hours ago
reply
good overview
Hamburg, Germany
alvinashcraft
13 days ago
reply
West Grove, PA
Share this story
Delete

Static initialization blocks in JavaScript classes (#tilPost)

1 Share

I've been reading Jake's guide on custom elements and when/where to define a new custom element. It's a good and quick read, so go check it out!

His final custom element snippet includes something I haven't seen before.

class MyElement extends HTMLElement {
  // Static class property
  static tag = "my-element";

  // Static class method
  static define(tag = this.tag) {
    // ...
  }

  // 👇 What's this is?
  static {
    // ...
  }
}

The class defines two static class members. One is a class property (tag), and the other is a class method (define). Both static class members can be called without initializing the class and creating an instance.

class MyElement extends HTMLElement {
  static tag = "my-element";

  static define(tag = this.tag) {
    console.log('define was called...')
  }
}

// access the `tag` property without initializing a new class instance
console.log(MyElement.tag); // 'my-element'

// call the `define` method without initializing a new class instance
MyElement.define(); // 'define was called...'

Cool, that's what static is for. But what's up with this nameless static block?

Apparently, JavaScript classes support static initialization blocks. These initialization blocks are called during class initialization (don't confuse them with when you initialize a new object of a class).

So, whenever the JavaScript parser rolls over and parses the class MyElement, it'll call its static block. That's neat!

class MyElement extends HTMLElement {
  static {
    console.log('class was initialized...')
  }
} 

// automatically logs "class was initialized..."

Static initialization blocks can be extremely handy for conditionally registering custom elements (as Jake showed) or dynacially (uhm... dynamically?! 😅) setting up a shared property across class instances. I could think of a shared database connection or something like this.

But can we all use static initialization blocks today?

MDN Compat Data (source)
Browser support info for Class static initialization blocks
chrome chrome_android edge firefox firefox_android safari safari_ios samsunginternet_android webview_android
94 94 94 93 93 16.4 16.4 17.0 94

Apparently, static initialization blocks have been part of ECMAScript 2022, and they're supported across all browsers. Well, today I learned!


Reply to Stefan
Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete

A 2-ply minimax chess engine in 84,688 regular expressions

1 Share

Can chess, with regular expressions? Yes. Can chess, with regular expressions.

Over the holidays I decided it's been too long since I did something with entirely no purpose. So without further ado, I present to you ... Regex Chess: sequence of 84,688 regular expressions that, when executed in order, will play a (valid; not entirely terrible) move given a chess board as input. Here, I'll show you.

M

o

v

e

n

o

t

a

t

i

o

n

:

[

s

r

c

]

[

d

e

s

t

]

(

e

.

g

.

e

2

e

4

)

o

r

'

q

'

t

o

q

u

i

t

[

C

a

s

t

l

i

n

g

R

i

g

h

t

s

:

K

Q

k

q

,

E

n

P

a

s

s

a

n

t

:

-

]

Current executing regular expression will show here...

a8

b8

c8

d8

e8

f8

g8

h8

a7

b7

c7

d7

e7

f7

g7

h7

a6

b6

c6

d6

e6

f6

g6

h6

a5

b5

c5

d5

e5

f5

g5

h5

a4

b4

c4

d4

e4

f4

g4

h4

a3

b3

c3

d3

e3

f3

g3

h3

a2

b2

c2

d2

e2

f2

g2

h2

a1

b1

c1

d1

e1

f1

g1

h1

Specifically, this is the entirety of the program that is playing a move against you (no really, I'm not kidding, it really is this short):

let regex_list = [/* a very long list of regular expressions */]

let board = "rnbqkbnr / pppppppp / 8 / 8 / 8 / 8 / PPPPPPPP / RNBQKBNR w KQkq - 0 1";

for (regex of regex_list) {

    board = re.replace(regex.pattern, regex.target)

}

display(board)

By the end of this post you'll (hopefully) understand why this sequence of regular Now some snobby people when they see this are going to say something like "yoU SaiD yoU'Re GoIng to uSe rEGUlar EXPresSIONs buT thESE ArE nOT ReGULaR !!" I do not care. expressions is possible, and also what the specific regular expressions do.

(If you're someone who subscribed to this blog in the last six months or so, and have gotten accustom to me writing about "serious" and "important" things, please treat this as your fair warning that this is MY WEBSITE and I MAKE THE RULES HERE and so today you're learning about RegexChess whether you like it or not.)

As always, code for this project is available on GitHub.


Getting Started: A Regular Expression CPU

So how are we going to get regular expressions to play chess? Well, by making a regular expression computer, of course! More specifically, we're going to design a Branch-Free, Conditional-Execution, Single-Instruction Multiple-Data instruction set. And then make a sequence of regular expressions that interpret these instructions. (Kind of like a GPU instruction set. And a bit of ARM. But a lot slower.) And then from there we can program our new computer to play chess. So let's get started.

(Some people may say I have an unhealthy obsession with building weird computers, c.f. my game of life computer or my printf computer. Those people are wrong, and just have an unhealthy obsession with the banal and ordinary.)


Computer Design

Let me get started by explaining how I'm going to organize the data that the computer is going to operate over. Because we're working with regular expressions, the current state of the computer is going to be represented as a single string containing both the program "stack", and all variables in the following format:

%% #stack: top item on stack second item on stack .... #variable1: value 1 #variable2: value 2 ... #variablek: value k

Each instruction will either manipulate some variables on the stack, or will read or write to a given variable. Let's look at some basic instructions.

Basic Stack Operations

The Push Instruction

Here's the implementation of the push command that adds a value to the top of the stack:

def push(const):

    return [(r"(%%\n#stack:\n)",

             r"\g<1>"+const+r"\n")]

You should read the return type of these functions as a list of tuples. Each tuple represents a regex transformation to apply, where the left element is the pattern to match, and the right element is the replacement.

As a brief regular expression refresher. Each tuple in this list has two parts: the regular expression, and the replacement. A regular expression will match a string if it can find a substring of whatever it's being applied against (the state, in our case). Most characters in a regular expression match themselves, but parentheses create a "match group" that can be referenced later.

The second argument is the replacement string. Again, most characters mean "Replace with this character", but special sequences like \g<1> are back-references that refer to previously captured groups. In this case, \g<1> references the first captured group (anything matched within the first set of parentheses)---which in this case is the "%%\n#stack:\n" header.

So, as a result of this operation executing on the stack, we find the occurrence of "%%\n#stack:\n" in the state, and insert the constant value below that (to the top of the stack).

Let's see this in practice. Say we start with an empty stack:

%% #stack:

If we execute push("hello"), the regular expression will:

  • Match the pattern %%\n#stack:\n at the start of our state
  • Capture this header into group 1 (the parentheses in the pattern create this capture group)
  • Replace it with the captured group (\g<1>) followed by our constant "hello" and a newline

This gives us:

%% #stack: hello

If we then execute push("world"), the same process repeats and we get:

%% #stack: world hello

The regular expression always matches at the top of the stack area, so new items get pushed on top while preserving the existing stack contents below them.

The Pop Instruction

The pop instruction removes the top element from the stack:

def pop():

    return [(r"(%%\n#stack:\n)([^\n]*)\n",

            r"\1")]

Here we start to see a few of the special operators that make regular expressions powerful. The [^\n] block means "match any character that isn't a newline" and the * means "match zero or more of these." So taken together we're looking for a line that starts with "%%\n#stack:\n", and then on the next line, zero or more characters that aren't a newline (so, the entire line). The replacement is just the first line, which has the effect of removing the second line, popping the top of the stack.

Let's see how this works in practice. Say we start with this stack:

%% #stack: world hello

When we execute pop(), the regular expression will:

  • Match the pattern beginning with %%\n#stack:\n (captured in group 1)
  • Match any characters until the next newline (captured in group 2 - the "world")
  • Replace everything matched with just group 1 (the header), effectively removing the top element

This gives us:

%% #stack: hello

Each pop operation removes exactly one element from the top of the stack, preserving any remaining elements below it.

Variable <-> Stack Instructions

Variable Lookup

To load the contents of a variable onto the top of the stack:

def lookup(variable):

    # Find the variable's value and push it onto the stack                                                

    return [(r"(%%\n#stack:)([^%]*\n#"+variable+": )([^\n]*)\n",

            r"\1\n\3\2\3\n")]

This regular expression is a bit more complex than our previous ones. Let's break down what it does:

  • The [^%]* matches basically any character (% only appears at the start of the program) and so lets us find any variable anywhere in the program.
  • The [^\n]* matches the variable's value by capturing everything until the end of the line
  • The replacement creates a copy of the value and places it on top of the stack

Let's see how this works in practice. Say we start with this state:

%% #stack: #foo: hello #bar: world #baz: 42

If we execute lookup("bar"), the regular expression will:

  • Match the stack header in group 1
  • Match everything up to and including "#bar: " in group 2
  • Match "world" in group 3
  • Use these groups to reconstruct the state with the value copied to the top of the stack

And so running the replacement will result in the following state:

%% #stack: world #foo: hello #bar: world #baz: 42

The lookup operation preserved the original variable and its value while also placing a copy of the value on top of the stack. This allows us to read variable values without modifying them.

Variable Assignment

Assigning to a variable presents an interesting challenge: we don't know if the variable already exists. We need to handle both cases: updating an existing variable or creating a new one.

Let me show you the implementation and then I'll walk you through it case by case.

def assign_pop(varname):

    return [

        (r"(%%)\n#stack:\n([^\n]*)\n" +

           "([^%]*#" + varname + r": )[^\n]*",

        r"\1`\n#stack:\n\3\2"),

        (r"(%%)([^`]\n?#stack:\n)([^\n%]*)\n([^%]*)",

        r"\1`\2\4#" + varname + r": \3\n"),

        (r"%%`",

        r"%%")

    ]

To begin let's assume the variable already exists. That is, the stack starts off looking like this, and assume we're calling assign_pop("bar"):

%% #stack: world #foo: hello #bar: something #othervar: othervalue

When we run this regular expression list, we create the following capture groups:

%% #stack: world #foo: hello #bar: something #othervar: othervalue

After the replacement operation, we get this output:

%%` #stack: #foo: hello #bar: world #othervar: othervalue

Now we proceed on to the next instruction, and we don't match it because the second regex fails if there's a back-tick after the program start %%. So nothing happens. And then finally, the third regex cleans things up for us.

Handling Non-Existent Variables: Let's consider what happens if the variable doesn't already exist. Again, assume we're calling assign_pop("bar"):

%% #stack: world #foo: hello #othervar: othervalue

The first regex tries to match but fails because there is no "#bar" anywhere. So it doesn't do anything. But now the second regex tries to match and succeeds. It creates the following capture groups:

%% #stack: world #foo: hello #othervar: othervalue

From here, we perform the rewrite and get the following output:

%% #stack: #foo: hello #othervar: othervalue #bar: world

And then the third regex is applied and does nothing.

There are lots of instructions that use this trick to make sure we don't apply things in the order we don't want. For example, as an exercise for yourself, try to understand how the "is equal" instruction works:

def eq():

    return [

        (r"(%%\n#stack:\n)([^\n]*)\n\2\n",

         r"\1`True\n"),

        (r"(%%\n#stack:\n)([^`][^\n]*)\n([^\n]*)\n",

         r"\1False\n"),


(Branch-Free) Conditionals

Programming languages, in order to be interesting, usually need to have some kind of control flow. It's very hard to write some program without ever having an if statement. So let's now show how we're going to do this. (And I hope you did your homework, because we're going to use the same conditional execution trick again!) Here's the implementation of a conditional instruction:

def cond(tag):

    return [(r"%(%\n#stack:\nTrue)",

             r"%\1`"),

            (r"%(\n#stack:\nFalse)",

             tag+r"\1`"),

            (r"\n(True|False)`\n",

             "\n")]

Let's walk through how this is going to work, starting with the case where the top of the stack is False.

%% #stack: False #variable: value

Initially, the first regex will fail to match, because the top element on the stack isn't True. So we go to the next regex, and see if it applies. This one does match, and makes the corresponding match groups.

%% #stack: False #variable: value

After we apply the replacement, we get the following stack.

%tag #stack: False` #variable: value

(And finally, using the same cleanup trick, we'll remove the used marker.)

Now see what happened here? The program no longer begins with %%. This means that EVERY instruction will fail to match, because they always make sure that the program begins with %%. So nothing else will happen.... until we reactivate it later with the following simple instruction:

def reactivate(tag):

    return [(r"%"+tag+r"\n([^%]*)",

        r"%%\n\1")]

Let's now return to the True case for the conditional. This is the easy case: we basically don't do anything at all. We replace the stack with True` on the second regex, and then delete this line on the third. Easy.

Notice that this means our code is actually branch-free, because every instruction is a conditional instruction. (Kind of like ARM's predicated execution, where most instructions can be conditionally executed based on status flags rather than using explicit branch instructions.)


Loops (are impossible)

Because our program just consists of a sequence of regular expressions, you can't loop at all! That, technically, means we can't actually perform Turing Complete But we can do any bounded computation by just unrolling any loops we may have. And fortunately computing the next move in a chess position is a bounded computation, so we can do just that.

Single-Instruction Multiple-Data

And now for my absolute favorite part of the language we've developed. By the magic of regular expressions (and the fact that they perform substitution globally over the entire string), we can run multiple threads simultaneously!

That is, if we just write our state string as:

%% #stack: int0000101010 int0001011100 %% #stack: int0000001101 int0110000000

When we call binary_add(), both additions happen simultaneously! After execution:

%% #stack: int0010001110 %% #stack: int0110001101

The reason this happens is because the regular expression matches work globally. When we match the "begin thread" operator (%%) twice, we get to perform operations on both threads simultaneously.

So how do we actually make use of this feature? Let's look at some instructions that help us create and manage threads.


The Fork Instructions

Here's a simple fork instruction that splits every currently running thread into two, with the second one starting off inactive with a given tag:

def fork_inactive(tag):

    return [(r"%%\n([^%]*)",

             r"%%\n\1" + "%"+tag+r"\n\1")

            ]

We can also, for example, fork() on a boolean, giving one thread the True case and another the False case. (This is something like McCarthy's Amb operator reference)

def fork_bool(variable):

    return [(r"%%\n([^%]*)",

             r"%%\n\1#"+variable+r": True\n%%\n\1#"+variable+r": False\n")

Let's see what happens when we apply multiple forks. Starting with a simple state:

%% #stack: somevalue #x: 10

After calling fork_bool("condition"), we get:

%% #stack: somevalue #x: 10 #condition: True %% #stack: somevalue #x: 10 #condition: False

If we then call fork_bool("c2"), each existing thread splits into two:

%% #stack: somevalue #x: 10 #condition: True #c2: True %% #stack: somevalue #x: 10 #condition: True #c2: False %% #stack: somevalue #x: 10 #condition: False #c2: True %% #stack: somevalue #x: 10 #condition: False #c2: False

Now we have four simultaneous execution paths, exploring every possible combination of our boolean conditions at the same time. This is exceptionally useful for chess, when we might frequently want to consider multiple possible board states at the same time, and (for example) score them to see which is best. Instead of having to loop over every possible board state, we can just pretend we were doing it once but have them all happen at the same time.


Compiling to our little language

Now that we have our CPU emulator, we can build a compiler to target our new assembly language.

"But wait I'm not reading this post for a lesson in compilers!" you say?? Fair point. Also I didn't go into this project trying to build a compiler, so instead what I have is more of a macro-assembler. It turns python-ish programs like this:

def fib():

    a = 1

    b = 2

    for _ in range(10):

        next = a + b

        a = b

        b = next

Into a sequence of instructions like this:

push(1) assign_pop('a') push(2) assign_pop('b') lookup('a') lookup('b') binary_add() assign_pop('next') lookup('b') assign_pop('a') lookup('next') assign_pop('b') [... repeated 8 more times ...]


Compiling Through Symbolic Execution

Rather than writing a traditional compiler with parsing and code generation phases, I took an unusual approach: symbolic execution. The key insight is that we can "execute" the Python code in a special way that records what operations would happen rather than actually performing them.

Here's how it works: the variables argument isn't actually a dictionary---it's a special object that records every operation performed on it. It creates what we call a "trace" of the execution. When you write:

a = b + 1

The tracer object records four operations:

  1. A lookup of variable 'b'
  2. A push of the constant 1
  3. A binary_add operation
  4. An assign_pop to variable 'a'

Handling Control Flow

The trickiest part of this approach is handling branching control flow---if statements, specifically. (What about loops? We don't have those, so I never use them. Loops can only have constants.) We need to make sure we capture all possible execution paths. Here's how we do it:

When we hit a conditional, we create two separate paths in our trace---one for when the condition is true, and one for when it's false. Each path records its own sequence of operations. Later, we merge these paths back together.

For example, this Python code:

if x > 0:

    y = 1

else:

    y = 2

Generates something like this trace structure:

lookup('x') # Get x's value push(0) # Push 0 greater_than() # Compare cond('tag1') # Branch on result # True path: push(1) assign_pop('y') pause('tag2') reactivate('tag1') # False path: push(2) assign_pop('y') reactivate('tag2')

The magic of the compiler lies in how it handles control flow through branching. Let me explain this in a little bit more detail.

When our compiler first starts processing code, it maintains a single linear path of instructions in the CallTree. Each instruction gets appended one after another as we go. This list of instructions looks entirely linear. When we reach a conditional statement, though, things get interesting.

Consider what happens when we hit a conditional statement like the x > 0 conditional above. When this happens, I detect it, and create a branch in the call tree representing the two paths simultaneously. Then, I just pretend this conditional was true, and fill out the true case of the tree. When we reach the end of the program we've done some of our compilation, but not all of it.

Now comes the clever part. When compiling, we don't just trace the program once. We trace many times. And each time we trace, we arrange for the conditionals to go through whichever branch has been taken least often. In this way, the second time around, we record the instructions for the false branch.

Finally, once the conditional is over, we detect this and merge the two branches back together. This branching and merging mechanism is more than just a clever trick---it's essential to how our regex-based CPU actually works. When we convert this tree into instructions, each branch gets translated into a conditional regex operation (using our cond instruction) that can selectively activate and deactivate different parts of our program state. The merge points become reactivate instructions that ensure we continue execution on the correct path.


Writing a Chess Engine

Okay, so we're finally to the part of this post where we can actually start writing a chess engine. (And at the part where some of the emulator design decisions will make sense.)

For the most part, this is entirely straightforward and mirrors how you would write a chess engine in any other programming language. But this branching thing with SIMD is what gives us the power to make things go fast.

Let's consider the following (simplified) program that calculates all the valid pawn moves.

def pawn_moves(initial_board):

    # Step 1: Find all pawns and create a list of their positions 

    pawnpos_list = find_all_pawns(initial_board)

    # Step 2: Create parallel states for each pawn (up to 8)

    MAX_PAWNS = 8

    for iteration in range(MAX_PAWNS):

        if not pawn_list.is_empty():

            pawn_pos = pawnpos_lst.remove_first()

            fork_inactive("waiting")

    # Step 3: Switch to processing the parallel states

    pause("main")

    reactivate("inactive")

    # Step 4: Generate moves for all pawns simultaneously 

    candidate_moves = []

    if initial_board[pawn_pos + (0, 1)].is_empty():

        candidate_moves.append(pawn_pos + (0, 1))

        if pawn_pos.y == 2:

            if initial_board[pawn_pos + (0, 2)].is_empty():

                candidate_moves.append(pawn_pos + (0, 2))

    if initial_board[pawn_pos + (1, 1)].has_opponent():

        candidate_moves.append(pawn_pos + (1, 1))

    if initial_board[pawn_pos + (-1, 1)].has_opponent():

        candidate_moves.append(pawn_pos + (-1, 1))

    # Step 5: Switch back and merge results 

    pause("inactive")

    reactivate("main")

    candidate_moves = merge_variables_from_threads("inactive")

Step 1: Finding the Pawns

The find_all_pawns() function scans through our board representation, looking for white pawns (represented as 'P' in the FEN string). It returns a list of the position of each of these pawns. As an example, if we run our program on the following position with three pawns on d2, e2, and f2, this creates a semicolon-separated list in the pawnpos_lst variable as follows

%% #stack: #board: 4k3/8/8/8/8/8/3PPP2/4K3 #pawnpos_lst: d2;e2;f2;

Step 2: State Creation

Now comes the clever part. The fork_inactive instruction, as described above, duplicates our entire program state. Each time it runs, it creates an exact copy of the currently running thread, but marks the new copy with %waiting instead of %%. (Recall this means instructions won't apply to this thread.) At the same time, it takes one position from our pawnpos_lst and assigns it to a new variable pawnpos in the copied state.

When our loop runs three times, each fork_inactive operation splits off a new parallel universe where we'll process a different pawn. The regex operation that does this copying preserves all existing variables but adds the new pawnpos variable with the specific position for that copy to process.

%% #stack: #board: 4k3/8/8/8/8/8/3PPP2/4K3 #pawnpos_lst: %waiting #stack: #board: 4k3/8/8/8/8/8/3PPP2/4K3 #pawnpos: d2 %waiting #stack: #board: 4k3/8/8/8/8/3PPP2/4K3 #pawnpos: e2 %waiting #stack: #board: 4k3/8/8/8/8/3PPP2/4K3 #pawnpos: f2

Step 3: Activation Switch

Recall that the pause and reactivate operations work by manipulating the %% markers that indicate which states are active. The pause("main") operation changes our original %% to %main, effectively deactivating it. Then reactivate("inactive") finds all states marked with %waiting and changes them to %%, making them the new active states.

Step 4: Parallel Move Generation

Here's where the SIMD magic happens. Each check for a possible move---forward one square, forward two squares, or diagonal captures---executes across all active states simultaneously. When we check if the square ahead is empty, we're actually checking d3, e3, and f3 all in one operation. For each valid move, we add this to the candidate_moves list.

(I've significantly simplified the program for visual purposes here. In reality I don't work directly over the FEN strings but expand them to 64 independent variables for each of the 64 squares and read and write to these squares directly. This makes processing the board state much easier. But for the purpose of this blog post it's easier to show visually as if it worked on FEN strings alone.)

%main #stack: #board: 4k3/8/8/8/8/8/3PPP2/4K3 #pawnpos_lst: %% #stack: #board: 4k3/8/8/8/8/8/3PPP2/4K3 #pawnpos: d2 #candidate_moves: d3;d4 %% #stack: #board: 4k3/8/8/8/8/8/3PPP2/4K3 #pawnpos: e2 #candidate_moves: e3;e4 %% #stack: #board: 4k3/8/8/8/8/8/3PPP2/4K3 #pawnpos: f2 #candidate_moves: f3;f4

Step 5: Merging Results

The final merge operation combines all the candidate_moves lists from our parallel states. It first switches activation back to the main state using pause and reactivate. Then merge_variables_from_threads (again, a pseudo-op I've made up for visual clarity, in practice this requires like 10 different instructions on my real machine) match all the candidate_moves lists across our inactive states and concatenates them together.

What this means is that while the code we wrote code looks like it processes one pawn at a time, our regex engine's ability to process multiple states means we're actually handling all pawns simultaneously. Every operation, from checking empty squares to building move lists, happens in parallel across all our active states.

And this is how every piece operation works. Because this post is already getting quite long, I won't walk through each piece one by one, but if you're interested definitely go look at the chess-engine.py for the details.


Playing a Turn

Now let's walk through the overall game loop that handles everything.

def play_turn():

    # Step 1: Read the human entered move from the input                                                  

    board_before_move, src, dst = from_pretty_utf8_to_fen()

    # Step 2: Check if their move is valid                                                                

    after_move = board_before_move.make_move(src, dst)

    next_boards = compute_legal_boards(board_before_move)

    next_board = fork_on_list(next_boards)

    if after_move != next_board:

        destroy_active_thread()

    # Step 3: Generate the computer's reply                                                               

    candidate_boards = compute_and_score_legal_boards(after_move)

    candidate_board = fork_on_list(candidate_boards)

    keep_best_scoring_board(score)

    from_fen_to_pretty_utf8(candidate_board)

Say we're at the start of a game, and the human enters "e2e4" (the king's pawn opening). Here's how our code processes this:

Step 1: Reading the Move

Initially, the function from_pretty_utf8_to_fen() converts our pretty-printed board with Unicode chess pieces back into FEN notation. It also extracts the source and destination squares from the input:

%% #stack: #board: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR #src: e2 #dst: e4

Step 2: Move Validation

Now we need to check if this is a valid move. Rather than writing entirely new code that explicitly checks if a move is legal, we use our parallel processing power again. The process works in three stages:

First, make_move applies the human's move to create a new board state:

%% #stack: #board: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR #after_move: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR

Then compute_legal_boards generates all possible legal moves from the starting position, creating a list like:

%% #stack: #next_boards: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR; rnbqkbnr/pppppppp/8/8/8/3P4/PPP1PPPP/RNBQKBNR; rnbqkbnr/pppppppp/8/8/8/4P3/PPPP1PPP/RNBQKBNR; ...

Finally, fork_on_list creates parallel states for each legal board position:

%% #stack: #board: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR %% #stack: #board: rnbqkbnr/pppppppp/8/8/8/3P4/PPP1PPPP/RNBQKBNR %% #stack: #board: rnbqkbnr/pppppppp/8/8/8/4P3/PPPP1PPP/RNBQKBNR

The destroy_active_thread() call removes any thread where the board doesn't match after_move. In our case, only the e2-e4 position survives, confirming it's a legal move. (If no legal move is made, I have a special regex that will replace the entire output with just the hard-coded text "Illegal Move.")

Step 3: Computer's Reply

Now we repeat a similar process to find the best reply. First, compute_and_score_legal_boards generates all possible black responses. This function does a little bit of magic, and when it returns the next possible boards, it returns with each board the score of that board after whites next best move I'll explain how this works below. But suffice to know that what this function returns for now the possible positions as compute_legal_boards does, but also the score of the position. (This is where the MiniMax happens. The score here is how good each position is from the player's perspective, after they have made their best reply move.)

Read the whole story
emrox
6 days ago
reply
Hamburg, Germany
Share this story
Delete

Complex Django filters with Subquery

1 Share

Comments

Read the whole story
emrox
6 days ago
reply
Hamburg, Germany
Share this story
Delete

Node’s new built-in support for TypeScript

2 Shares

Starting with v23.6.0, Node.js supports TypeScript without any flags. This blog post explains how it works and what to look out for.

Read the whole story
emrox
6 days ago
reply
Hamburg, Germany
alvinashcraft
7 days ago
reply
West Grove, PA
Share this story
Delete
Next Page of Stories