Skip to content

Atomic Changes

Ahmad Saleem edited this page Sep 4, 2026 · 4 revisions

Atomic changes

An atomic change is done by the proxy, not by your server. The proxy handles one change at a time, so two servers can't lose each other's work.

Why you need it

A player has 100 coins and buys something on two servers at the same moment:

# WRONG
if {?coins::%uuid of player%} >= 100:
	remove 100 from {?coins::%uuid of player%}
	give player a diamond

Both servers read 100. Both think there's enough. Both subtract 100. The player gets two diamonds and pays for one.

Sequence diagram: lobby and survival both read a balance of 100, both decide they can afford it, and both write 0. One subtraction is lost.

Let the proxy do the check and the subtraction together:

# RIGHT
atomically remove 100 from {?coins::%uuid of player%} without going below 0 and wait
if the atomic change succeeded:
	give player a diamond
else:
	send "Not enough coins."

Sequence diagram: both servers send remove 100 floor 0 to the proxy. The proxy applies the first and refuses the second, so exactly one server wins.

The five forms

atomically add %number% to {?variable}
atomically remove %number% from {?variable}
atomically set {?variable} to %value% if it is not set
atomically set {?variable} to %value% if it is %value%
atomically remove %number% from {?variable} without going below %number%

All of them take and wait at the end.

Add and remove

atomically add 50 to {?coins::%uuid of player%}
atomically remove 10 from {?coins::%uuid of player%}

Numbers only. If the variable holds text, the proxy refuses:

{coins::123} holds a string, so it cannot be added to

Whole numbers stay exact, even above 9007199254740992 where normal decimal maths starts rounding.

A plain remove has no floor. It goes past zero without complaining:

atomically remove 500 from {?coins::%uuid of player%}
# balance is now -400

subtract works as a word too.

Spending safely

atomically remove 250 from {?coins::%uuid of player%} without going below 0 and wait

The proxy works out the answer first. If it would land below the floor, it refuses and changes nothing:

{coins::123} holds 70, so taking that much would put it below the floor

Landing exactly on the floor is allowed, so removing 70 from 70 with a floor of 0 succeeds and leaves 0.

The floor doesn't have to be 0:

atomically remove 25 from {?fuel::%uuid of player%} without going below -100 and wait

Use this form for anything a player spends.

Set only if empty

atomically set {?coins::%uuid of player%} to 500 if it is not set and wait

This gives a starting balance once for the whole network, whichever server sees the player first. If the value already exists, the proxy refuses:

already set

Works with any type, not just numbers.

Set only if it equals something

atomically set {?rank::%uuid of player%} to "vip" if it is "default" and wait

The proxy changes the value only if it's exactly what you said. Otherwise:

current value does not match

Two things trip people up here.

It never succeeds on a variable that doesn't exist yet. Use if it is not set for the first time, then switch:

set {_last} to {?cooldown::%uuid of player%}
if {_last} is not set:
	atomically set {?cooldown::%uuid of player%} to {_now} if it is not set and wait
else:
	atomically set {?cooldown::%uuid of player%} to {_now} if it is {_last} and wait

The value you compare has to be the same type as the stored one. The proxy compares byte for byte. Wrapping a number in a string turns it into text, and text never matches a stored number:

atomically set {?count::1} to 5 if it is "%{_old}%" and wait   # never matches
atomically set {?count::1} to 5 if it is {_old} and wait       # correct

Quotes are right when the value really is text, like a stored date.

This is how you claim something without two servers both claiming it:

atomically set {?arena::1} to network server name if it is not set and wait
if the atomic change succeeded:
	send "This server got the arena."
else:
	send "Already held by %{?arena::1}%"

Reading the answer with "and wait"

Without and wait, the change is sent and your script carries on. You never find out what happened.

With and wait, your trigger pauses until the proxy answers.

atomically add 50 to {?coins::%uuid of player%} and wait
send "You now have %the atomic result% coins."

the atomic result is the value the proxy holds after the change. For an add, that's the new total. It's correct before the new value even reaches your server's copy.

Without and wait, this prints the old number:

atomically add 50 to {?coins::%uuid of player%}
send "You have %{?coins::%uuid of player%}%"   # probably still the old value

Three answers, not two

Condition Meaning
the atomic change succeeded The proxy applied it.
the atomic change was refused The proxy did not apply it. Nothing happened.
the atomic change timed out No answer arrived. It may or may not have applied.
the atomic change failed Refused or timed out. Use only if you treat both the same.
the atomic change was answered An answer arrived, yes or no.

Only a refusal proves nothing happened. On a timeout you don't know, so never give a reward and never tell the player they weren't charged.

atomically remove 100 from {?coins::%uuid of player%} without going below 0 and wait
if the atomic change succeeded:
	give player a diamond
	send "Bought. You have %the atomic result% left."
else if the atomic change timed out:
	send "The network is slow right now. Try again in a moment."
else:
	send "Not enough: %the atomic error%"

the atomic error holds the refusal message, or the reason no answer came. It's empty when the change succeeded.

The wait is 5 seconds by default. Change it with atomic-timeout in the config.

These are conditions, not expressions

the atomic change succeeded is a condition. You can't put it inside %...%.

# WRONG, this will not load
send "ok=%the atomic change succeeded%"

Skript says Can't understand this expression. Use if and else:

# RIGHT
if the atomic change succeeded:
	send "ok"
else:
	send "not ok"

the atomic result and the atomic error are expressions, so those work in a string.

"and wait" is a delay

and wait behaves like Skript's own wait effect. Everything after it runs on a later tick, so the usual delay rules apply. You can't cancel the event after that line, and it doesn't belong inside a function.

Local variables survive the wait, and a loop keeps its place:

set {_total} to 0
loop 3 times:
	atomically add 10 to {?score::%uuid of player%} and wait
	set {_total} to the atomic result
send "Total is %{_total}%"   # 30

Two triggers waiting at once each get their own answer. They can't read each other's.

What can't be changed atomically

Local variables, because they never leave the server:

atomically add 1 to {_count}   # will not load

Lists:

atomically add 1 to {?scores::*}   # will not load

Variables with no prefix:

atomically add 1 to {coins}   # will not load

Skript reports all three when the script loads.

Every refusal message

These are the exact messages you get back in the atomic error.

Message Cause
already set if it is not set on a value that exists
current value does not match if it is X and the value was not X
{name} holds a string, so it cannot be added to add or remove on a non numeric value
{name} holds N, so taking that much would put it below the floor the floor blocked it
the result does not fit in a whole number the number grew past the whole number limit
can only add numbers, not 'X' the amount you gave was not a number
{name} is a list, so it can only be deleted the name ended in ::*
no value attached the value was empty or could not be sent

See also

Clone this wiki locally