An implementation of a water animation for the ZX Spectrum using Sinclair BASIC and “rotating UDGs”, including a machine code version.

Download .tap file

A while ago I saw a thread on Twitter featuring a very nice pixel water animation and some great information about how it was done. It could be downloaded as a .png, and since then I have wanted to have a go at using it on the ZX Spectrum. All the complexity of the super smooth movement is contained within the five frames of animation, so it felt like something that might work surprisingly well.

I also saw a nice trick recently in the Animated Alex Discord about a water effect created by “rotating UDGs”. In other words, you POKE a system variable that contains the start location of the User Defined Graphics so that the same UDGs can be PRINTed to the screen but rotated by cycling that value, which simulates waves moving across the ocean. Credit goes to WhatHoSnorkers for this.

Basically, if you have a 16x16 tile represented by "ABCD", then shifting the system variable by 4*8 bytes, i.e., 32, animates that tile to "EFGH" when it is redrawn. This can be cycled using a FOR loop or the line LET offset = offset + 32 AND offset < 127, then POKE 23675, offset + 88, for the five frames. I tried this first in Sinclair BASIC and then in machine code, with both versions using the same UDG rotation trick.

A quick demo

Play it now

A few notes on what I did

Talk is cheap, show me the code.

I managed to split up the .png and convert it into Z80 defb statements using a combination of ImageMagick and some custom code rolled into a .exe that became part of the build pipeline. It is all very rough and ready, so I have only included the .exe for now, but I will open source it once I have tidied it up and made it configurable for any size of .png and any number of frames. I then reused a PowerShell script from my last project to convert this into DATA statements so it could be included in the BASIC listing.

It worked, but it was also very “choppy”, if you will excuse the pun. I am generally trying to keep things BASIC-y, so I did try to improve it using something known as the DEFADD trick, but at that point it started to feel a bit like yak shaving. I still want to come back to that, but for now I decided to build a machine code version instead. This still uses the UDG rotation trick, and POKEing the system variable lets you switch between UDG banks, or even the default character set, so the machine code routine can also display normal text. Useful!

This time I did not convert the machine code routine into DATA statements. Instead, I used the BASIC listing as a loader for the pasmo-generated .tap file, and concatenating the .tap files in the build pipeline “just works”. It also uses a screen buffer that is fully populated before calling LDIR to flush the data to the screen as quickly as possible and reduce choppiness. This takes up a fair chunk of memory, 6144 bytes, so it is probably not something you would do for anything other than a small demo, but I wanted to give the Speccy the best chance possible. It still flickers, because the time it takes to copy 6144 bytes with LDIR is longer than the time between screen refreshes, or interrupts. Oh well.

The zmakebas version, which does not include the appended DATA statements for the UDGs but is more readable, is as follows.

@begin:
CLEAR 58971
LOAD ""CODE
REM gfx_txt(row, column, width, height, string$)
DEF FN A(R,C,W,H,S$) = USR 58972

LET a$=""
LET b$=""
LET s$=""
FOR i=1 TO 16
    LET a$=a$+"\a\b"
    LET b$=b$+"\c\d"
NEXT i
FOR i=1 TO 11
    LET s$=s$+a$+b$
NEXT i

GO SUB @loadudgs

INK 1 : PAPER 5 : CLS

PRINT #1; AT 0,0; "Press SPACE to switch"
PRINT #1; AT 1,0; "BASIC"
LET basic=1
LET mc=0

LET offset = 0
@main_loop:
REM shift UDG start position to simulate animation
LET offset = offset + 32 AND offset < 127
POKE 23675, offset + 88
REM BASIC version
IF basic = 1 THEN PRINT AT 0,0; s$
REM m/c version (also temporarily points UDGs at normal font to redisplay instructions)
IF mc = 1 THEN RANDOMIZE FN A(0,0,32,22," ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !""#""#""#""#""#""#""#""#""#""#""#""#""#""#""#""#") : POKE 23675 , 0 : POKE 23676 , 61 : RANDOMIZE FN A(22,0,21,2,"Press SPACE to switchmachine code         ") : POKE 23675, 88 + offset : POKE 23676, 255
REM read key
LET key = PEEK 23560
REM swap versions
IF key = 32 AND basic = 1 THEN LET basic = 0 : LET mc = 1 : POKE 23560, 0 : GO TO @main_loop
IF key = 32 AND mc = 1 THEN LET mc = 0 : LET basic = 1 : PRINT #1; AT 1,0; "BASIC       " : POKE 23560, 0 : GO TO @main_loop
GO TO @main_loop

The following is the ZX-Basicus optimised Sinclair BASIC version, which also has the UDGs appended. This is generated by the build pipeline.

10 CLEAR 58971 :  LOAD "" CODE  :  DEFFN A ( R , C , W , H , S$ )  =  USR 58972 :  LET a$ = "" :  LET b$ = "" :  LET s$ = ""
24 FOR i = 1 TO 16 :  LET a$ = a$ + "\udg(AB)" :  LET b$ = b$ + "\udg(CD)" :  NEXT i
32 FOR i = 1 TO 11 :  LET s$ = s$ + a$ + b$ :  NEXT i :  GOSUB 80
40 INK 1 :  PAPER 5 :  CLS 
42 PRINT  # 1 ;  AT 0 , 0 ; "Press SPACE to switch" :  PRINT  # 1 ;  AT 1 , 0 ; "BASIC" :  LET c = 1 :  LET a = 0 :  LET d = 0
54 LET d = d + 32 AND d < 127 :  POKE 23675 , d + 88 :  IF c = 1 THEN  PRINT  AT 0 , 0 ; s$
64 IF a = 1 THEN  RANDOMIZE  FN A ( 0 , 0 , 32 , 22 , " ! ! ! ! ! ! ! ! ! ! ! ! ! ! ! !""#""#""#""#""#""#""#""#""#""#""#""#""#""#""#""#" )  :  POKE 23675 , 0 :  POKE 23676 , 61 :  RANDOMIZE  FN A ( 22 , 0 , 21 , 2 , "Press SPACE to switchmachine code         " )  :  POKE 23675 , 88 + d :  POKE 23676 , 255
68 LET b =  PEEK 23560 :  IF b = 32 AND c = 1 THEN  LET c = 0 :  LET a = 1 :  POKE 23560 , 0 :  GOTO 52
74 IF b = 32 AND a = 1 THEN  LET a = 0 :  LET c = 1 :  PRINT  # 1 ;  AT 1 , 0 ; "BASIC       " :  POKE 23560 , 0 :  GOTO 52
76 GOTO 52
80 RESTORE 86 :  LET i =  USR "a" :  LET z = i + 160 - 1 :  FOR x = i TO z :  READ y :  POKE x , y :  NEXT x :  RETURN 
86 DATA 224 , 221 , 189 , 56 , 199 , 223 , 191 , 191
88 DATA 123 , 251 , 241 , 204 , 61 , 187 , 183 , 143 :  DATA 191 , 28 , 195 , 239 , 111 , 7 , 231 , 243 :  DATA 111 , 119 , 120 , 182 , 207 , 223 , 222 , 129 :  DATA 222 , 158 , 24 , 227 , 231 , 223 , 223 , 223 :  DATA 123 , 249 , 118 , 14 , 222 , 221 , 195 , 155 :  DATA 143 , 224 , 243 , 119 , 7 , 119 , 243 , 224 :  DATA 61 , 60 , 152 , 199 , 207 , 207 , 192 , 59 :  DATA 239 , 14 , 113 , 243 , 247 , 239 , 207 , 119 :  DATA 120 , 59 , 135 , 207 , 238 , 225 , 237 , 158 :  DATA 248 , 251 , 119 , 135 , 187 , 121 , 254 , 247 :  DATA 62 , 28 , 131 , 199 , 227 , 192 , 29 , 125 :  DATA 166 , 128 , 185 , 123 , 247 , 231 , 57 , 126 :  DATA 27 , 231 , 231 , 231 , 246 , 240 , 247 , 15 :  DATA 126 , 125 , 129 , 157 , 222 , 63 , 255 , 119 :  DATA 159 , 204 , 227 , 243 , 227 , 28 , 60 , 60 :  DATA 226 , 217 , 185 , 121 , 113 , 158 , 191 , 127 :  DATA 99 , 247 , 247 , 195 , 248 , 123 , 119 , 15 :  DATA 127 , 60 , 128 , 207 , 223 , 31 , 15 , 199 :  DATA 79 , 230 , 243 , 115 , 173 , 158 , 158 , 157